Decision now support auto mailing
This commit is contained in:
parent
1b615163be
commit
3ab1c05fcc
72
app/Http/Controllers/Admin/EmailLogController.php
Normal file
72
app/Http/Controllers/Admin/EmailLogController.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailTemplate;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class EmailLogController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->authorize('viewAny', EmailTemplate::class); // reuse same permission gate for admin area
|
||||
|
||||
$query = EmailLog::query()
|
||||
->with(['template:id,name'])
|
||||
->orderByDesc('created_at');
|
||||
|
||||
$status = trim((string) $request->input('status', ''));
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if ($email = trim((string) $request->input('to'))) {
|
||||
$query->where('to_email', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], $email).'%');
|
||||
}
|
||||
if ($subject = trim((string) $request->input('subject'))) {
|
||||
$query->where('subject', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], $subject).'%');
|
||||
}
|
||||
if ($templateId = (int) $request->input('template_id')) {
|
||||
$query->where('template_id', $templateId);
|
||||
}
|
||||
if ($from = $request->date('date_from')) {
|
||||
$query->whereDate('created_at', '>=', $from);
|
||||
}
|
||||
if ($to = $request->date('date_to')) {
|
||||
$query->whereDate('created_at', '<=', $to);
|
||||
}
|
||||
|
||||
$logs = $query->paginate(20)->withQueryString();
|
||||
$templates = EmailTemplate::query()->orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('Admin/EmailLogs/Index', [
|
||||
'logs' => $logs,
|
||||
'filters' => [
|
||||
'status' => $status,
|
||||
'to' => $email ?? '',
|
||||
'subject' => $subject ?? '',
|
||||
'template_id' => $templateId ?: null,
|
||||
'date_from' => $request->input('date_from'),
|
||||
'date_to' => $request->input('date_to'),
|
||||
],
|
||||
'templates' => $templates,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(EmailLog $emailLog): Response
|
||||
{
|
||||
$this->authorize('viewAny', EmailTemplate::class);
|
||||
|
||||
$emailLog->load(['template:id,name', 'body']);
|
||||
|
||||
return Inertia::render('Admin/EmailLogs/Show', [
|
||||
'log' => $emailLog,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,26 +4,24 @@
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEmailTemplateRequest;
|
||||
use App\Http\Requests\UpdateEmailTemplateRequest;
|
||||
use App\Jobs\SendEmailTemplateJob;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Document;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\MailProfile;
|
||||
use App\Models\Person\Person;
|
||||
use App\Services\EmailTemplateRenderer;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\Mailer\Mailer as SymfonyMailer;
|
||||
use Symfony\Component\Mailer\Transport;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
|
||||
|
||||
|
|
@ -31,6 +29,19 @@ class EmailTemplateController extends Controller
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function update(\App\Http\Requests\UpdateEmailTemplateRequest $request, EmailTemplate $emailTemplate)
|
||||
{
|
||||
$this->authorize('update', $emailTemplate);
|
||||
$data = $request->validated();
|
||||
$emailTemplate->fill($data)->save();
|
||||
// Move any tmp images referenced in HTML into permanent storage and attach as documents
|
||||
$this->adoptTmpImages($emailTemplate);
|
||||
|
||||
return redirect()->route('admin.email-templates.edit', $emailTemplate)->with('success', 'Template updated');
|
||||
}
|
||||
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
$this->authorize('viewAny', EmailTemplate::class);
|
||||
|
|
@ -59,126 +70,45 @@ public function store(StoreEmailTemplateRequest $request)
|
|||
return redirect()->route('admin.email-templates.edit', $tpl)->with('success', 'Template created');
|
||||
}
|
||||
|
||||
public function edit(EmailTemplate $emailTemplate): Response
|
||||
{
|
||||
$this->authorize('update', $emailTemplate);
|
||||
$emailTemplate->load(['documents' => function ($q) {
|
||||
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
|
||||
}]);
|
||||
|
||||
return Inertia::render('Admin/EmailTemplates/Edit', [
|
||||
'template' => $emailTemplate,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateEmailTemplateRequest $request, EmailTemplate $emailTemplate)
|
||||
{
|
||||
$data = $request->validated();
|
||||
$emailTemplate->update($data);
|
||||
$this->adoptTmpImages($emailTemplate);
|
||||
|
||||
return back()->with('success', 'Template updated');
|
||||
}
|
||||
|
||||
public function destroy(EmailTemplate $emailTemplate)
|
||||
{
|
||||
$this->authorize('delete', $emailTemplate);
|
||||
$emailTemplate->delete();
|
||||
|
||||
return redirect()->route('admin.email-templates.index')->with('success', 'Template deleted');
|
||||
}
|
||||
|
||||
public function preview(Request $request, EmailTemplate $emailTemplate)
|
||||
/**
|
||||
* Render a quick preview of the email template with the provided context.
|
||||
* Does not persist any changes or inline CSS; intended for fast editor feedback.
|
||||
*/
|
||||
public function preview(Request $request, EmailTemplate $emailTemplate): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $emailTemplate);
|
||||
|
||||
$renderer = app(EmailTemplateRenderer::class);
|
||||
$subject = (string) $request->input('subject', $emailTemplate->subject_template);
|
||||
$html = (string) $request->input('html', $emailTemplate->html_template);
|
||||
$text = (string) $request->input('text', $emailTemplate->text_template);
|
||||
|
||||
// Resolve sample entities by ID if given
|
||||
$ctx = [];
|
||||
// Prefer contract -> case -> client for deriving person
|
||||
if ($id = $request->integer('contract_id')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
if ($contract) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
$ctx['client_case'] = $contract->clientCase;
|
||||
if ($contract->clientCase->client) {
|
||||
$ctx['client'] = $contract->clientCase->client;
|
||||
$ctx['person'] = optional($contract->clientCase->client)->person;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) {
|
||||
$case = ClientCase::query()->with(['client.person'])->find($id);
|
||||
if ($case) {
|
||||
$ctx['client_case'] = $case;
|
||||
if ($case->client) {
|
||||
$ctx['client'] = $case->client;
|
||||
$ctx['person'] = optional($case->client)->person;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) {
|
||||
$client = Client::query()->with(['person'])->find($id);
|
||||
if ($client) {
|
||||
$ctx['client'] = $client;
|
||||
$ctx['person'] = optional($client)->person;
|
||||
}
|
||||
}
|
||||
$ctx['extra'] = (array) $request->input('extra', []);
|
||||
|
||||
$result = $renderer->render([
|
||||
'subject' => $subject,
|
||||
'html' => $html,
|
||||
'text' => $text,
|
||||
], $ctx);
|
||||
|
||||
// Repair and attach images, then embed as requested
|
||||
if (! empty($result['html'])) {
|
||||
$result['html'] = $this->repairImgWithoutSrc($result['html']);
|
||||
$result['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $result['html']);
|
||||
|
||||
$embed = (string) $request->input('embed', 'base64'); // hosted | base64
|
||||
if ($embed === 'base64') {
|
||||
try {
|
||||
$imageInliner = app(\App\Services\EmailImageInliner::class);
|
||||
$result['html'] = $imageInliner->inline($result['html']);
|
||||
} catch (\Throwable $e) {
|
||||
// ignore preview image inlining errors
|
||||
}
|
||||
} else {
|
||||
$result['html'] = $this->absolutizeStorageUrls($request, $result['html']);
|
||||
}
|
||||
|
||||
try {
|
||||
$inliner = new CssToInlineStyles;
|
||||
$result['html'] = $inliner->convert($result['html']);
|
||||
} catch (\Throwable $e) {
|
||||
// ignore preview inlining errors
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
{
|
||||
$this->authorize('send', $emailTemplate);
|
||||
|
||||
$renderer = app(EmailTemplateRenderer::class);
|
||||
$subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
|
||||
$html = (string) ($request->input('html') ?? $emailTemplate->html_template);
|
||||
$text = (string) ($request->input('text') ?? $emailTemplate->text_template);
|
||||
|
||||
// Adopt tmp images (tmp/email-images) so test email can display images; also persist
|
||||
$html = $this->adoptTmpImagesInHtml($emailTemplate, $html, true);
|
||||
// Do not persist tmp images for preview, but allow showing them if already accessible
|
||||
// Optionally repair missing img src and attach from template documents for a better preview
|
||||
if (! empty($html)) {
|
||||
$html = $this->repairImgWithoutSrc($html);
|
||||
$html = $this->attachSrcFromTemplateDocuments($emailTemplate, $html);
|
||||
}
|
||||
|
||||
// Context resolution (shared logic with renderFinalHtml)
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
// Derive base entities from activity when not explicitly provided
|
||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||
$ctx['contract'] = $activity->contract;
|
||||
}
|
||||
if ($activity->clientCase && ! isset($ctx['client_case'])) {
|
||||
$ctx['client_case'] = $activity->clientCase;
|
||||
}
|
||||
if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
|
||||
$ctx['client'] = $ctx['client_case']->client;
|
||||
$ctx['person'] = optional($ctx['client'])->person;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($id = $request->integer('contract_id')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
if ($contract) {
|
||||
|
|
@ -217,107 +147,131 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
|||
'text' => $text,
|
||||
], $ctx);
|
||||
|
||||
return response()->json([
|
||||
'subject' => $rendered['subject'] ?? $subject,
|
||||
'html' => (string) ($rendered['html'] ?? $html ?? ''),
|
||||
'text' => (string) ($rendered['text'] ?? $text ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(EmailTemplate $emailTemplate): Response
|
||||
{
|
||||
$this->authorize('update', $emailTemplate);
|
||||
$emailTemplate->load(['documents' => function ($q) {
|
||||
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
|
||||
}]);
|
||||
|
||||
return Inertia::render('Admin/EmailTemplates/Edit', [
|
||||
'template' => $emailTemplate,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
{
|
||||
$this->authorize('send', $emailTemplate);
|
||||
|
||||
$renderer = app(EmailTemplateRenderer::class);
|
||||
$subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
|
||||
$html = (string) ($request->input('html') ?? $emailTemplate->html_template);
|
||||
$text = (string) ($request->input('text') ?? $emailTemplate->text_template);
|
||||
|
||||
// Adopt tmp images (tmp/email-images) so test email can display images; also persist
|
||||
$html = $this->adoptTmpImagesInHtml($emailTemplate, $html, true);
|
||||
|
||||
// Context resolution
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||
$ctx['contract'] = $activity->contract;
|
||||
}
|
||||
if ($activity->clientCase && ! isset($ctx['client_case'])) {
|
||||
$ctx['client_case'] = $activity->clientCase;
|
||||
}
|
||||
if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
|
||||
$ctx['client'] = $ctx['client_case']->client;
|
||||
$ctx['person'] = optional($ctx['client'])->person;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($id = $request->integer('contract_id')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
if ($contract) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
$ctx['client_case'] = $contract->clientCase;
|
||||
if ($contract->clientCase->client) {
|
||||
$ctx['client'] = $contract->clientCase->client;
|
||||
$ctx['person'] = optional($contract->clientCase->client)->person;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) {
|
||||
$case = ClientCase::query()->with(['client.person'])->find($id);
|
||||
if ($case) {
|
||||
$ctx['client_case'] = $case;
|
||||
if ($case->client) {
|
||||
$ctx['client'] = $case->client;
|
||||
$ctx['person'] = optional($case->client)->person;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) {
|
||||
$client = Client::query()->with(['person'])->find($id);
|
||||
if ($client) {
|
||||
$ctx['client'] = $client;
|
||||
$ctx['person'] = optional($client)->person;
|
||||
}
|
||||
}
|
||||
$ctx['extra'] = (array) $request->input('extra', []);
|
||||
|
||||
// Render preview values; we store a minimal snapshot on the log
|
||||
$rendered = $renderer->render([
|
||||
'subject' => $subject,
|
||||
'html' => $html,
|
||||
'text' => $text,
|
||||
], $ctx);
|
||||
|
||||
$to = (string) $request->input('to');
|
||||
if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
return back()->with('error', 'Invalid target email');
|
||||
}
|
||||
|
||||
// First repair images missing src if they are followed by a URL (editor artifact)
|
||||
if (! empty($rendered['html'])) {
|
||||
$rendered['html'] = $this->repairImgWithoutSrc($rendered['html']);
|
||||
$rendered['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $rendered['html']);
|
||||
}
|
||||
// Embed images as requested (default hosted for Gmail compatibility)
|
||||
$htmlForSend = $rendered['html'] ?? '';
|
||||
$embed = (string) $request->input('embed', 'base64');
|
||||
// Prepare EmailLog record with queued status
|
||||
$log = new EmailLog;
|
||||
$log->fill([
|
||||
'uuid' => (string) \Str::uuid(),
|
||||
'template_id' => $emailTemplate->id,
|
||||
'to_email' => $to,
|
||||
'to_name' => null,
|
||||
'subject' => (string) ($rendered['subject'] ?? $subject ?? ''),
|
||||
'body_html_hash' => $rendered['html'] ? hash('sha256', $rendered['html']) : null,
|
||||
'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null,
|
||||
'embed_mode' => (string) $request->input('embed', 'base64'),
|
||||
'status' => EmailLogStatus::Queued,
|
||||
'queued_at' => now(),
|
||||
'client_id' => $ctx['client']->id ?? null,
|
||||
'client_case_id' => $ctx['client_case']->id ?? null,
|
||||
'contract_id' => $ctx['contract']->id ?? null,
|
||||
'extra_context' => $ctx['extra'] ?? null,
|
||||
'ip' => $request->ip(),
|
||||
]);
|
||||
$log->save();
|
||||
|
||||
// Prefer the active Mail Profile for sending test emails
|
||||
$subject = $rendered['subject'] ?? '';
|
||||
$profile = MailProfile::query()
|
||||
->where('active', true)
|
||||
->orderBy('priority')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
// Store bodies in companion table (optional, enabled here)
|
||||
$log->body()->create([
|
||||
'body_html' => (string) ($rendered['html'] ?? ''),
|
||||
'body_text' => (string) ($rendered['text'] ?? ''),
|
||||
'inline_css' => true,
|
||||
]);
|
||||
|
||||
try {
|
||||
if ($profile) {
|
||||
$host = $profile->host;
|
||||
$port = (int) ($profile->port ?: 587);
|
||||
$encryption = $profile->encryption ?: 'tls';
|
||||
$username = $profile->username ?: '';
|
||||
$password = (string) ($profile->decryptPassword() ?? '');
|
||||
// Dispatch the queued job
|
||||
dispatch(new SendEmailTemplateJob($log->id));
|
||||
|
||||
$scheme = $encryption === 'ssl' ? 'smtps' : 'smtp';
|
||||
$query = $encryption === 'tls' ? '?encryption=tls' : '';
|
||||
$dsn = sprintf('%s://%s:%s@%s:%d%s', $scheme, rawurlencode($username), rawurlencode($password), $host, $port, $query);
|
||||
|
||||
$transport = Transport::fromDsn($dsn);
|
||||
$mailer = new SymfonyMailer($transport);
|
||||
|
||||
$fromAddr = $profile->from_address ?: ($username ?: (config('mail.from.address') ?? ''));
|
||||
$fromName = $profile->from_name ?: (config('mail.from.name') ?? config('app.name'));
|
||||
|
||||
$email = (new Email)
|
||||
->from(new Address($fromAddr ?: $to, $fromName ?: null))
|
||||
->to($to)
|
||||
->subject($subject);
|
||||
|
||||
if (! empty($rendered['text'])) {
|
||||
$email->text($rendered['text']);
|
||||
}
|
||||
if (! empty($htmlForSend)) {
|
||||
if ($embed === 'base64') {
|
||||
try {
|
||||
$imageInliner = app(\App\Services\EmailImageInliner::class);
|
||||
$htmlForSend = $imageInliner->inline($htmlForSend);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
} else {
|
||||
$htmlForSend = $this->absolutizeStorageUrls($request, $htmlForSend);
|
||||
}
|
||||
try {
|
||||
$inliner = new CssToInlineStyles;
|
||||
$htmlForSend = $inliner->convert($htmlForSend);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
$email->html($htmlForSend);
|
||||
}
|
||||
|
||||
$mailer->send($email);
|
||||
} else {
|
||||
// Fallback to default Laravel mailer
|
||||
if (! empty($htmlForSend)) {
|
||||
if ($embed === 'base64') {
|
||||
try {
|
||||
$imageInliner = app(\App\Services\EmailImageInliner::class);
|
||||
$htmlForSend = $imageInliner->inline($htmlForSend);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
} else {
|
||||
$htmlForSend = $this->absolutizeStorageUrls($request, $htmlForSend);
|
||||
}
|
||||
try {
|
||||
$inliner = new CssToInlineStyles;
|
||||
$htmlForSend = $inliner->convert($htmlForSend);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
Mail::html($htmlForSend, function ($message) use ($to, $subject, $rendered) {
|
||||
$message->to($to)->subject($subject);
|
||||
if (! empty($rendered['text'])) {
|
||||
$message->text('mail::raw', ['slot' => $rendered['text']]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Mail::raw($rendered['text'] ?? '', function ($message) use ($to, $subject) {
|
||||
$message->to($to)->subject($subject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Test email sent to '.$to);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', 'Failed to send test email: '.$e->getMessage());
|
||||
}
|
||||
return back()->with('success', 'Test email queued for '.$to);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -338,6 +292,22 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
|||
|
||||
// Context resolution (same as sendTest)
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||
$ctx['contract'] = $activity->contract;
|
||||
}
|
||||
if ($activity->clientCase && ! isset($ctx['client_case'])) {
|
||||
$ctx['client_case'] = $activity->clientCase;
|
||||
}
|
||||
if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
|
||||
$ctx['client'] = $ctx['client_case']->client;
|
||||
$ctx['person'] = optional($ctx['client'])->person;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($id = $request->integer('contract_id')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
if ($contract) {
|
||||
|
|
@ -712,6 +682,28 @@ public function replaceImage(Request $request, EmailTemplate $emailTemplate)
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attached image Document from the given email template.
|
||||
*/
|
||||
public function deleteImage(Request $request, EmailTemplate $emailTemplate, Document $document)
|
||||
{
|
||||
$this->authorize('update', $emailTemplate);
|
||||
|
||||
// Ensure the document belongs to this template (polymorphic relation)
|
||||
if ((int) $document->documentable_id !== (int) $emailTemplate->id || $document->documentable_type !== EmailTemplate::class) {
|
||||
return response()->json(['message' => 'Document does not belong to this template.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
// Force delete to remove underlying file as well (Document model handles file deletion on force delete)
|
||||
$document->forceDelete();
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to delete image: '.$e->getMessage()], 500);
|
||||
}
|
||||
|
||||
return response()->json(['deleted' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan HTML for images stored in /storage/tmp/email-images and move them into a permanent
|
||||
* location under /storage/email-images, create Document records and update the HTML.
|
||||
|
|
|
|||
|
|
@ -126,14 +126,15 @@ public function sendTest(Request $request, MailProfile $mailProfile)
|
|||
$mailer = new SymfonyMailer($transport);
|
||||
|
||||
$fromAddr = $mailProfile->from_address ?: $username;
|
||||
$fromName = $mailProfile->from_name ?: config('app.name');
|
||||
$fromName = (string) ($mailProfile->from_name ?: (config('mail.from.name') ?? config('app.name') ?? ''));
|
||||
|
||||
$html = '<p>This is a <strong>test email</strong> from profile <code>'.e($mailProfile->name).'</code> at '.e(now()->toDateTimeString()).'.</p>';
|
||||
$text = 'This is a test email from profile "'.$mailProfile->name.'" at '.now()->toDateTimeString().'.';
|
||||
|
||||
// Build email
|
||||
$fromAddress = $fromName !== '' ? new Address($fromAddr, $fromName) : new Address($fromAddr);
|
||||
$email = (new Email)
|
||||
->from(new Address($fromAddr, $fromName))
|
||||
->from($fromAddress)
|
||||
->to($to)
|
||||
->subject('Test email - '.$mailProfile->name)
|
||||
->text($text)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class ClientCaseContoller extends Controller
|
|||
public function index(ClientCase $clientCase, Request $request)
|
||||
{
|
||||
$query = $clientCase::query()
|
||||
->with(['person', 'client.person'])
|
||||
->with(['person.client', 'client.person'])
|
||||
->where('active', 1)
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
$que->whereHas('person', function ($q) use ($search) {
|
||||
|
|
@ -251,6 +251,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
'action_id' => 'exists:\App\Models\Action,id',
|
||||
'decision_id' => 'exists:\App\Models\Decision,id',
|
||||
'contract_uuid' => 'nullable|uuid',
|
||||
'send_auto_mail' => 'sometimes|boolean',
|
||||
]);
|
||||
|
||||
// Map contract_uuid to contract_id within the same client case, if provided
|
||||
|
|
@ -279,6 +280,23 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
|
||||
logger()->info('Activity successfully inserted', $attributes);
|
||||
|
||||
// Auto mail dispatch (best-effort)
|
||||
try {
|
||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||||
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag);
|
||||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||||
// If template requires contract and user attempted to send, surface a validation message
|
||||
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
|
||||
}
|
||||
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
|
||||
return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Do not fail activity creation due to mailing issues
|
||||
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
|
||||
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
|
||||
return back(303)->with('success', 'Successful created!');
|
||||
|
|
@ -1020,7 +1038,7 @@ protected function streamDocumentForDisk(Document $document, bool $inline = true
|
|||
public function show(ClientCase $clientCase)
|
||||
{
|
||||
$case = $clientCase::with([
|
||||
'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts']),
|
||||
'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']),
|
||||
])->where('active', 1)->findOrFail($clientCase->id);
|
||||
|
||||
$types = [
|
||||
|
|
@ -1174,7 +1192,7 @@ public function show(ClientCase $clientCase)
|
|||
}
|
||||
|
||||
return Inertia::render('Cases/Show', [
|
||||
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(),
|
||||
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(),
|
||||
'client_case' => $case,
|
||||
'contracts' => $contracts,
|
||||
'archive_meta' => [
|
||||
|
|
@ -1209,11 +1227,17 @@ function ($p) {
|
|||
'documents' => $mergedDocs,
|
||||
'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(),
|
||||
'account_types' => \App\Models\AccountType::all(),
|
||||
'actions' => \App\Models\Action::with('decisions')
|
||||
/*->when($segmentId, function($q) use($segmentId) {
|
||||
$q->where('segment_id', $segmentId)->orWhereNull('segment_id');
|
||||
})*/
|
||||
->get(),
|
||||
// Include decisions with auto-mail metadata and the linked email template entity_types for UI logic
|
||||
'actions' => \App\Models\Action::query()
|
||||
->with([
|
||||
'decisions' => function ($q) {
|
||||
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
|
||||
},
|
||||
'decisions.emailTemplate' => function ($q) {
|
||||
$q->select('id', 'name', 'entity_types');
|
||||
},
|
||||
])
|
||||
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||
'types' => $types,
|
||||
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
|
||||
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ public function show(Client $client, Request $request)
|
|||
{
|
||||
|
||||
$data = $client::query()
|
||||
->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts', 'emails'])])
|
||||
->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts', 'emails', 'client'])])
|
||||
->findOrFail($client->id);
|
||||
|
||||
$types = [
|
||||
|
|
|
|||
|
|
@ -2,32 +2,26 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Person\Person;
|
||||
use App\Models\BankAccount;
|
||||
use App\Models\Person\Person;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PersonController extends Controller
|
||||
{
|
||||
//
|
||||
public function show(Person $person){
|
||||
|
||||
}
|
||||
public function show(Person $person) {}
|
||||
|
||||
public function create(Request $request){
|
||||
public function create(Request $request) {}
|
||||
|
||||
}
|
||||
public function store(Request $request) {}
|
||||
|
||||
public function store(Request $request){
|
||||
|
||||
}
|
||||
|
||||
public function update(Person $person, Request $request){
|
||||
public function update(Person $person, Request $request)
|
||||
{
|
||||
$attributes = $request->validate([
|
||||
'full_name' => 'string|max:255',
|
||||
'tax_number' => 'nullable|integer',
|
||||
'social_security_number' => 'nullable|integer',
|
||||
'description' => 'nullable|string|max:500'
|
||||
'description' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$person->update($attributes);
|
||||
|
|
@ -37,17 +31,18 @@ public function update(Person $person, Request $request){
|
|||
'full_name' => $person->full_name,
|
||||
'tax_number' => $person->tax_number,
|
||||
'social_security_number' => $person->social_security_number,
|
||||
'description' => $person->description
|
||||
]
|
||||
'description' => $person->description,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function createAddress(Person $person, Request $request){
|
||||
public function createAddress(Person $person, Request $request)
|
||||
{
|
||||
$attributes = $request->validate([
|
||||
'address' => 'required|string|max:150',
|
||||
'country' => 'nullable|string',
|
||||
'type_id' => 'required|integer|exists:address_types,id',
|
||||
'description' => 'nullable|string|max:125'
|
||||
'description' => 'nullable|string|max:125',
|
||||
]);
|
||||
|
||||
// Dedup: avoid duplicate address per person by (address, country)
|
||||
|
|
@ -57,7 +52,7 @@ public function createAddress(Person $person, Request $request){
|
|||
], $attributes);
|
||||
|
||||
return response()->json([
|
||||
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id)
|
||||
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +62,7 @@ public function updateAddress(Person $person, int $address_id, Request $request)
|
|||
'address' => 'required|string|max:150',
|
||||
'country' => 'nullable|string',
|
||||
'type_id' => 'required|integer|exists:address_types,id',
|
||||
'description' => 'nullable|string|max:125'
|
||||
'description' => 'nullable|string|max:125',
|
||||
]);
|
||||
|
||||
$address = $person->addresses()->with(['type'])->findOrFail($address_id);
|
||||
|
|
@ -75,7 +70,7 @@ public function updateAddress(Person $person, int $address_id, Request $request)
|
|||
$address->update($attributes);
|
||||
|
||||
return response()->json([
|
||||
'address' => $address
|
||||
'address' => $address,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +78,7 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
|||
{
|
||||
$address = $person->addresses()->findOrFail($address_id);
|
||||
$address->delete(); // soft delete
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +88,7 @@ public function createPhone(Person $person, Request $request)
|
|||
'nu' => 'required|string|max:50',
|
||||
'country_code' => 'nullable|integer',
|
||||
'type_id' => 'required|integer|exists:phone_types,id',
|
||||
'description' => 'nullable|string|max:125'
|
||||
'description' => 'nullable|string|max:125',
|
||||
]);
|
||||
|
||||
// Dedup: avoid duplicate phone per person by (nu, country_code)
|
||||
|
|
@ -102,7 +98,7 @@ public function createPhone(Person $person, Request $request)
|
|||
], $attributes);
|
||||
|
||||
return response()->json([
|
||||
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id)
|
||||
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +108,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
|
|||
'nu' => 'required|string|max:50',
|
||||
'country_code' => 'nullable|integer',
|
||||
'type_id' => 'required|integer|exists:phone_types,id',
|
||||
'description' => 'nullable|string|max:125'
|
||||
'description' => 'nullable|string|max:125',
|
||||
]);
|
||||
|
||||
$phone = $person->phones()->with(['type'])->findOrFail($phone_id);
|
||||
|
|
@ -120,7 +116,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
|
|||
$phone->update($attributes);
|
||||
|
||||
return response()->json([
|
||||
'phone' => $phone
|
||||
'phone' => $phone,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +124,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
|
|||
{
|
||||
$phone = $person->phones()->findOrFail($phone_id);
|
||||
$phone->delete(); // soft delete
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
|
|
@ -139,6 +136,7 @@ public function createEmail(Person $person, Request $request)
|
|||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'receive_auto_mails' => 'sometimes|boolean',
|
||||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
|
|
@ -149,9 +147,7 @@ public function createEmail(Person $person, Request $request)
|
|||
'value' => $attributes['value'],
|
||||
], $attributes);
|
||||
|
||||
return response()->json([
|
||||
'email' => \App\Models\Email::findOrFail($email->id)
|
||||
]);
|
||||
return back()->with('success', 'Email added successfully');
|
||||
}
|
||||
|
||||
public function updateEmail(Person $person, int $email_id, Request $request)
|
||||
|
|
@ -162,6 +158,7 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
|||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'receive_auto_mails' => 'sometimes|boolean',
|
||||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
|
|
@ -171,15 +168,14 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
|||
|
||||
$email->update($attributes);
|
||||
|
||||
return response()->json([
|
||||
'email' => $email
|
||||
]);
|
||||
return back()->with('success', 'Email updated successfully');
|
||||
}
|
||||
|
||||
public function deleteEmail(Person $person, int $email_id, Request $request)
|
||||
{
|
||||
$email = $person->emails()->findOrFail($email_id);
|
||||
$email->delete();
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +199,7 @@ public function createTrr(Person $person, Request $request)
|
|||
$trr = $person->bankAccounts()->create($attributes);
|
||||
|
||||
return response()->json([
|
||||
'trr' => BankAccount::findOrFail($trr->id)
|
||||
'trr' => BankAccount::findOrFail($trr->id),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -227,7 +223,7 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
|
|||
$trr->update($attributes);
|
||||
|
||||
return response()->json([
|
||||
'trr' => $trr
|
||||
'trr' => $trr,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -235,6 +231,7 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
|||
{
|
||||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->delete();
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
use App\Models\Action;
|
||||
use App\Models\Decision;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\Segment;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class WorkflowController extends Controller
|
||||
|
|
@ -17,6 +17,7 @@ public function index(Request $request)
|
|||
'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->get(),
|
||||
'decisions' => Decision::query()->with('actions')->withCount('activities')->get(),
|
||||
'segments' => Segment::query()->get(),
|
||||
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ public function storeAction(Request $request)
|
|||
'segment_id' => $attributes['segment_id'] ?? null,
|
||||
]);
|
||||
|
||||
if (!empty($decisionIds)) {
|
||||
if (! empty($decisionIds)) {
|
||||
$row->decisions()->sync($decisionIds);
|
||||
}
|
||||
});
|
||||
|
|
@ -59,12 +60,12 @@ public function updateAction(int $id, Request $request)
|
|||
'segment_id' => 'nullable|integer|exists:segments,id',
|
||||
'decisions' => 'nullable|array',
|
||||
'decisions.*.id' => 'required_with:decisions.*|integer|exists:decisions,id',
|
||||
'decisions.*.name' => 'required_with:decisions.*|string|max:50'
|
||||
'decisions.*.name' => 'required_with:decisions.*|string|max:50',
|
||||
]);
|
||||
|
||||
$decisionIds = collect($attributes['decisions'] ?? [])->pluck('id')->toArray();
|
||||
|
||||
\DB::transaction(function() use ($attributes, $decisionIds, $row) {
|
||||
\DB::transaction(function () use ($attributes, $decisionIds, $row) {
|
||||
$row->update([
|
||||
'name' => $attributes['name'],
|
||||
'color_tag' => $attributes['color_tag'],
|
||||
|
|
@ -81,6 +82,8 @@ public function storeDecision(Request $request)
|
|||
$attributes = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'color_tag' => 'nullable|string|max:25',
|
||||
'auto_mail' => 'sometimes|boolean',
|
||||
'email_template_id' => 'nullable|integer|exists:email_templates,id',
|
||||
'actions' => 'nullable|array',
|
||||
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
|
||||
'actions.*.name' => 'required_with:actions.*|string|max:50',
|
||||
|
|
@ -93,9 +96,11 @@ public function storeDecision(Request $request)
|
|||
$row = Decision::create([
|
||||
'name' => $attributes['name'],
|
||||
'color_tag' => $attributes['color_tag'] ?? null,
|
||||
'auto_mail' => (bool) ($attributes['auto_mail'] ?? false),
|
||||
'email_template_id' => $attributes['email_template_id'] ?? null,
|
||||
]);
|
||||
|
||||
if (!empty($actionIds)) {
|
||||
if (! empty($actionIds)) {
|
||||
$row->actions()->sync($actionIds);
|
||||
}
|
||||
});
|
||||
|
|
@ -110,6 +115,8 @@ public function updateDecision(int $id, Request $request)
|
|||
$attributes = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'color_tag' => 'nullable|string|max:25',
|
||||
'auto_mail' => 'sometimes|boolean',
|
||||
'email_template_id' => 'nullable|integer|exists:email_templates,id',
|
||||
'actions' => 'nullable|array',
|
||||
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
|
||||
'actions.*.name' => 'required_with:actions.*|string|max:50',
|
||||
|
|
@ -121,6 +128,8 @@ public function updateDecision(int $id, Request $request)
|
|||
$row->update([
|
||||
'name' => $attributes['name'],
|
||||
'color_tag' => $attributes['color_tag'] ?? null,
|
||||
'auto_mail' => (bool) ($attributes['auto_mail'] ?? false),
|
||||
'email_template_id' => $attributes['email_template_id'] ?? null,
|
||||
]);
|
||||
$row->actions()->sync($actionIds);
|
||||
});
|
||||
|
|
@ -139,6 +148,7 @@ public function destroyAction(int $id)
|
|||
$row->decisions()->detach();
|
||||
$row->delete();
|
||||
});
|
||||
|
||||
return back()->with('success', 'Action deleted successfully!');
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +163,7 @@ public function destroyDecision(int $id)
|
|||
$row->actions()->detach();
|
||||
$row->delete();
|
||||
});
|
||||
|
||||
return back()->with('success', 'Decision deleted successfully!');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
app/Jobs/SendEmailTemplateJob.php
Normal file
60
app/Jobs/SendEmailTemplateJob.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Services\EmailSender;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
|
||||
class SendEmailTemplateJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public int $emailLogId)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$log = EmailLog::query()->find($this->emailLogId);
|
||||
if (! $log) {
|
||||
return;
|
||||
}
|
||||
if ($log->status === EmailLogStatus::Sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
$log->status = EmailLogStatus::Sending;
|
||||
$log->started_at = now();
|
||||
$log->attempt = (int) ($log->attempt ?: 0) + 1;
|
||||
$log->save();
|
||||
|
||||
try {
|
||||
/** @var EmailSender $sender */
|
||||
$sender = app(EmailSender::class);
|
||||
$result = $sender->sendFromLog($log);
|
||||
|
||||
$log->status = EmailLogStatus::Sent;
|
||||
$log->message_id = $result['message_id'] ?? ($log->message_id ?? null);
|
||||
$log->sent_at = now();
|
||||
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||
$log->save();
|
||||
} catch (Throwable $e) {
|
||||
$log->status = EmailLogStatus::Failed;
|
||||
$log->failed_at = now();
|
||||
$log->error_message = $e->getMessage();
|
||||
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||
$log->save();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,14 @@ class Decision extends Model
|
|||
/** @use HasFactory<\Database\Factories\DecisionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'color_tag'];
|
||||
protected $fillable = ['name', 'color_tag', 'auto_mail', 'email_template_id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'auto_mail' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function actions(): BelongsToMany
|
||||
{
|
||||
|
|
@ -29,4 +36,9 @@ public function activities(): HasMany
|
|||
{
|
||||
return $this->hasMany(\App\Models\Activity::class);
|
||||
}
|
||||
|
||||
public function emailTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\EmailTemplate::class, 'email_template_id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class Email extends Model
|
|||
'is_primary',
|
||||
'is_active',
|
||||
'valid',
|
||||
'receive_auto_mails',
|
||||
'verified_at',
|
||||
'preferences',
|
||||
'meta',
|
||||
|
|
@ -27,6 +28,7 @@ class Email extends Model
|
|||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'receive_auto_mails' => 'boolean',
|
||||
'verified_at' => 'datetime',
|
||||
'preferences' => 'array',
|
||||
'meta' => 'array',
|
||||
|
|
|
|||
86
app/Models/EmailLog.php
Normal file
86
app/Models/EmailLog.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
enum EmailLogStatus: string
|
||||
{
|
||||
case Queued = 'queued';
|
||||
case Sending = 'sending';
|
||||
case Sent = 'sent';
|
||||
case Failed = 'failed';
|
||||
case Bounced = 'bounced';
|
||||
case Deferred = 'deferred';
|
||||
}
|
||||
|
||||
class EmailLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'template_id',
|
||||
'mail_profile_id',
|
||||
'user_id',
|
||||
'message_id',
|
||||
'correlation_id',
|
||||
'to_email',
|
||||
'to_name',
|
||||
'cc',
|
||||
'bcc',
|
||||
'from_email',
|
||||
'from_name',
|
||||
'reply_to',
|
||||
'subject',
|
||||
'body_html_hash',
|
||||
'body_text_preview',
|
||||
'attachments',
|
||||
'embed_mode',
|
||||
'status',
|
||||
'error_code',
|
||||
'error_message',
|
||||
'transport',
|
||||
'headers',
|
||||
'attempt',
|
||||
'duration_ms',
|
||||
'client_id',
|
||||
'client_case_id',
|
||||
'contract_id',
|
||||
'extra_context',
|
||||
'queued_at',
|
||||
'started_at',
|
||||
'sent_at',
|
||||
'failed_at',
|
||||
'ip',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => EmailLogStatus::class,
|
||||
'cc' => 'array',
|
||||
'bcc' => 'array',
|
||||
'to_recipients' => 'array',
|
||||
'attachments' => 'array',
|
||||
'headers' => 'array',
|
||||
'extra_context' => 'array',
|
||||
'attempt' => 'integer',
|
||||
'duration_ms' => 'integer',
|
||||
'queued_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'sent_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailTemplate::class, 'template_id');
|
||||
}
|
||||
|
||||
public function body(): HasOne
|
||||
{
|
||||
return $this->hasOne(EmailLogBody::class, 'email_log_id');
|
||||
}
|
||||
}
|
||||
28
app/Models/EmailLogBody.php
Normal file
28
app/Models/EmailLogBody.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EmailLogBody extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'email_log_id',
|
||||
'body_html',
|
||||
'body_text',
|
||||
'inline_css',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'inline_css' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function log(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailLog::class, 'email_log_id');
|
||||
}
|
||||
}
|
||||
122
app/Services/AutoMailDispatcher.php
Normal file
122
app/Services/AutoMailDispatcher.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\SendEmailTemplateJob;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Decision;
|
||||
use App\Models\Email;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Models\EmailTemplate;
|
||||
|
||||
class AutoMailDispatcher
|
||||
{
|
||||
public function __construct(public EmailTemplateRenderer $renderer) {}
|
||||
|
||||
/**
|
||||
* Attempt to queue an auto mail for the given activity based on its decision/template.
|
||||
* Returns array with either ['queued' => true, 'log_id' => int] or ['skipped' => 'reason'].
|
||||
*/
|
||||
public function maybeQueue(Activity $activity, bool $sendFlag = true): array
|
||||
{
|
||||
$decision = $activity->decision;
|
||||
if (! $sendFlag || ! $decision || ! $decision->auto_mail || ! $decision->email_template_id) {
|
||||
return ['skipped' => 'disabled'];
|
||||
}
|
||||
|
||||
/** @var EmailTemplate|null $template */
|
||||
$template = EmailTemplate::query()->find($decision->email_template_id);
|
||||
if (! $template || ! $template->active) {
|
||||
return ['skipped' => 'no-template'];
|
||||
}
|
||||
|
||||
// Resolve context
|
||||
$clientCase = $activity->clientCase; /** @var ClientCase|null $clientCase */
|
||||
$contract = $activity->contract; /** @var Contract|null $contract */
|
||||
$client = $clientCase?->client; /** @var Client|null $client */
|
||||
$person = $clientCase?->person; /** @var \App\Models\Person\Person|null $person */
|
||||
|
||||
// Validate required entity types from template
|
||||
$required = (array) ($template->entity_types ?? []);
|
||||
if (in_array('client', $required, true) && ! $client) {
|
||||
return ['skipped' => 'missing-client'];
|
||||
}
|
||||
if (in_array('client_case', $required, true) && ! $clientCase) {
|
||||
return ['skipped' => 'missing-client-case'];
|
||||
}
|
||||
if (in_array('contract', $required, true) && ! $contract) {
|
||||
return ['skipped' => 'missing-contract'];
|
||||
}
|
||||
if (in_array('person', $required, true) && ! $person) {
|
||||
return ['skipped' => 'missing-person'];
|
||||
}
|
||||
|
||||
// Resolve eligible recipients: client's person emails with receive_auto_mails = true
|
||||
$recipients = [];
|
||||
if ($client && $client->person) {
|
||||
$recipients = Email::query()
|
||||
->where('person_id', $client->person->id)
|
||||
->where('is_active', true)
|
||||
->where('receive_auto_mails', true)
|
||||
->pluck('value')
|
||||
->map(fn ($v) => strtolower(trim((string) $v)))
|
||||
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
if (empty($recipients)) {
|
||||
return ['skipped' => 'no-recipients'];
|
||||
}
|
||||
|
||||
// Ensure related names are available without extra queries
|
||||
$activity->loadMissing(['action', 'decision']);
|
||||
|
||||
// Render content
|
||||
$rendered = $this->renderer->render([
|
||||
'subject' => (string) $template->subject_template,
|
||||
'html' => (string) $template->html_template,
|
||||
'text' => (string) $template->text_template,
|
||||
], [
|
||||
'client' => $client,
|
||||
'client_case' => $clientCase,
|
||||
'contract' => $contract,
|
||||
'person' => $person,
|
||||
'activity' => $activity,
|
||||
'extra' => [],
|
||||
]);
|
||||
|
||||
// Create the log and body
|
||||
$log = new EmailLog;
|
||||
$log->fill([
|
||||
'uuid' => (string) \Str::uuid(),
|
||||
'template_id' => $template->id,
|
||||
'to_email' => (string) ($recipients[0] ?? ''),
|
||||
'to_recipients' => $recipients,
|
||||
'subject' => (string) ($rendered['subject'] ?? $template->subject_template ?? ''),
|
||||
'body_html_hash' => $rendered['html'] ? hash('sha256', $rendered['html']) : null,
|
||||
'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null,
|
||||
'embed_mode' => 'base64',
|
||||
'status' => EmailLogStatus::Queued,
|
||||
'queued_at' => now(),
|
||||
'client_id' => $client?->id,
|
||||
'client_case_id' => $clientCase?->id,
|
||||
'contract_id' => $contract?->id,
|
||||
]);
|
||||
$log->save();
|
||||
|
||||
$log->body()->create([
|
||||
'body_html' => (string) ($rendered['html'] ?? ''),
|
||||
'body_text' => (string) ($rendered['text'] ?? ''),
|
||||
'inline_css' => true,
|
||||
]);
|
||||
|
||||
dispatch(new SendEmailTemplateJob($log->id));
|
||||
|
||||
return ['queued' => true, 'log_id' => $log->id];
|
||||
}
|
||||
}
|
||||
192
app/Services/EmailSender.php
Normal file
192
app/Services/EmailSender.php
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\MailProfile;
|
||||
use Symfony\Component\Mailer\Mailer as SymfonyMailer;
|
||||
use Symfony\Component\Mailer\Transport;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
|
||||
|
||||
class EmailSender
|
||||
{
|
||||
public function __construct(public EmailTemplateRenderer $renderer) {}
|
||||
|
||||
/**
|
||||
* Build and send the message described by the EmailLog. Returns ['message_id' => string|null].
|
||||
* Throws on transport errors so the Job can retry.
|
||||
*/
|
||||
public function sendFromLog(EmailLog $log): array
|
||||
{
|
||||
// Resolve sending profile
|
||||
$profile = null;
|
||||
if ($log->mail_profile_id) {
|
||||
$profile = MailProfile::query()->find($log->mail_profile_id);
|
||||
}
|
||||
if (! $profile) {
|
||||
$profile = MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
|
||||
}
|
||||
|
||||
$embed = $log->embed_mode ?: 'base64';
|
||||
|
||||
$subject = (string) $log->subject;
|
||||
$html = (string) optional($log->body)->body_html ?? '';
|
||||
$text = (string) optional($log->body)->body_text ?? '';
|
||||
|
||||
// Inline CSS and handle images similarly to controller
|
||||
if ($html !== '') {
|
||||
if ($embed === 'base64') {
|
||||
try {
|
||||
$imageInliner = app(\App\Services\EmailImageInliner::class);
|
||||
$html = $imageInliner->inline($html);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
} else {
|
||||
// Best effort absolutize /storage URLs using app.url
|
||||
$base = (string) (config('app.asset_url') ?: config('app.url'));
|
||||
$host = $base !== '' ? rtrim($base, '/') : null;
|
||||
if ($host) {
|
||||
$html = preg_replace_callback('#<img([^>]+)src=["\']([^"\']+)["\']([^>]*)>#i', function (array $m) use ($host) {
|
||||
$before = $m[1] ?? '';
|
||||
$src = $m[2] ?? '';
|
||||
$after = $m[3] ?? '';
|
||||
$path = $src;
|
||||
if (preg_match('#^https?://#i', $src)) {
|
||||
$parts = parse_url($src);
|
||||
$path = $parts['path'] ?? '';
|
||||
if (! preg_match('#^/?storage/#i', (string) $path)) {
|
||||
return $m[0];
|
||||
}
|
||||
} else {
|
||||
if (! preg_match('#^/?storage/#i', (string) $path)) {
|
||||
return $m[0];
|
||||
}
|
||||
}
|
||||
$rel = '/'.ltrim(preg_replace('#^/?storage/#i', 'storage/', (string) $path), '/');
|
||||
$abs = rtrim($host, '/').$rel;
|
||||
|
||||
return '<img'.$before.'src="'.$abs.'"'.$after.'>';
|
||||
}, $html);
|
||||
}
|
||||
}
|
||||
try {
|
||||
$inliner = new CssToInlineStyles;
|
||||
$html = $inliner->convert($html);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
// Transport setup (Symfony Mailer preferred when profile exists)
|
||||
$messageId = null;
|
||||
if ($profile) {
|
||||
$host = $profile->host;
|
||||
$port = (int) ($profile->port ?: 587);
|
||||
$encryption = $profile->encryption ?: 'tls';
|
||||
$username = $profile->username ?: '';
|
||||
$password = (string) ($profile->decryptPassword() ?? '');
|
||||
|
||||
$scheme = $encryption === 'ssl' ? 'smtps' : 'smtp';
|
||||
$query = $encryption === 'tls' ? '?encryption=tls' : '';
|
||||
$dsn = sprintf('%s://%s:%s@%s:%d%s', $scheme, rawurlencode($username), rawurlencode($password), $host, $port, $query);
|
||||
|
||||
$transport = Transport::fromDsn($dsn);
|
||||
$mailer = new SymfonyMailer($transport);
|
||||
|
||||
$fromAddr = (string) ($log->from_email ?: ($profile->from_address ?: ($username ?: (config('mail.from.address') ?? ''))));
|
||||
$fromName = (string) ($log->from_name ?: ($profile->from_name ?: (config('mail.from.name') ?? config('app.name') ?? '')));
|
||||
|
||||
// Build email with safe Address instances (Symfony Address does not allow null name)
|
||||
$fromAddress = $fromName !== ''
|
||||
? new Address($fromAddr ?: $log->to_email, $fromName)
|
||||
: new Address($fromAddr ?: $log->to_email);
|
||||
$toAddress = (string) ($log->to_name ?? '') !== ''
|
||||
? new Address($log->to_email, (string) $log->to_name)
|
||||
: new Address($log->to_email);
|
||||
|
||||
$email = (new Email)
|
||||
->from($fromAddress)
|
||||
->subject($subject);
|
||||
|
||||
// If multiple recipients are present, address to all; otherwise single to
|
||||
$toList = (array) ($log->to_recipients ?? []);
|
||||
if (! empty($toList)) {
|
||||
$addresses = [];
|
||||
foreach ($toList as $addr) {
|
||||
$addr = trim((string) $addr);
|
||||
if ($addr !== '' && filter_var($addr, FILTER_VALIDATE_EMAIL)) {
|
||||
$addresses[] = new Address($addr);
|
||||
}
|
||||
}
|
||||
if (! empty($addresses)) {
|
||||
$email->to(...$addresses);
|
||||
} else {
|
||||
$email->to($toAddress);
|
||||
}
|
||||
} else {
|
||||
$email->to($toAddress);
|
||||
}
|
||||
|
||||
if (! empty($text)) {
|
||||
$email->text($text);
|
||||
}
|
||||
if (! empty($html)) {
|
||||
$email->html($html);
|
||||
}
|
||||
if (! empty($log->reply_to)) {
|
||||
$email->replyTo($log->reply_to);
|
||||
}
|
||||
|
||||
$mailer->send($email);
|
||||
$headers = $email->getHeaders();
|
||||
$messageIdHeader = $headers->get('Message-ID');
|
||||
$messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null;
|
||||
} else {
|
||||
// Fallback to Laravel mailer
|
||||
if (! empty($html)) {
|
||||
\Mail::html($html, function ($message) use ($log, $subject, $text) {
|
||||
$toList = (array) ($log->to_recipients ?? []);
|
||||
if (! empty($toList)) {
|
||||
$message->to($toList);
|
||||
} else {
|
||||
$toName = (string) ($log->to_name ?? '');
|
||||
if ($toName !== '') {
|
||||
$message->to($log->to_email, $toName);
|
||||
} else {
|
||||
$message->to($log->to_email);
|
||||
}
|
||||
}
|
||||
$message->subject($subject);
|
||||
if (! empty($log->reply_to)) {
|
||||
$message->replyTo($log->reply_to);
|
||||
}
|
||||
if (! empty($text)) {
|
||||
// Provide a plain text alternative when available
|
||||
$message->text($text);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
\Mail::raw($text ?: '', function ($message) use ($log, $subject) {
|
||||
$toList = (array) ($log->to_recipients ?? []);
|
||||
if (! empty($toList)) {
|
||||
$message->to($toList);
|
||||
} else {
|
||||
$toName = (string) ($log->to_name ?? '');
|
||||
if ($toName !== '') {
|
||||
$message->to($log->to_email, $toName);
|
||||
} else {
|
||||
$message->to($log->to_email);
|
||||
}
|
||||
}
|
||||
$message->subject($subject);
|
||||
if (! empty($log->reply_to)) {
|
||||
$message->replyTo($log->reply_to);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ['message_id' => $messageId];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,19 +2,21 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Person\Person;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class EmailTemplateRenderer
|
||||
{
|
||||
/**
|
||||
* Render subject and bodies using a simple {{ key }} replacement.
|
||||
* Supported entities: client, person, client_case, contract
|
||||
* Supported entities: client, person, client_case, contract, activity
|
||||
*
|
||||
* @param array{subject:string, html?:string|null, text?:string|null} $template
|
||||
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, extra?:array} $ctx
|
||||
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
|
||||
* @return array{subject:string, html?:string, text?:string}
|
||||
*/
|
||||
public function render(array $template, array $ctx): array
|
||||
|
|
@ -40,16 +42,66 @@ public function render(array $template, array $ctx): array
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, extra?:array} $ctx
|
||||
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
|
||||
*/
|
||||
protected function buildMap(array $ctx): array
|
||||
{
|
||||
$formatDateEu = static function ($value): string {
|
||||
if ($value === null || $value === '') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return Carbon::instance($value)->format('d.m.Y');
|
||||
}
|
||||
|
||||
// Accept common formats (Y-m-d, Y-m-d H:i:s, etc.)
|
||||
return Carbon::parse((string) $value)->format('d.m.Y');
|
||||
} catch (\Throwable $e) {
|
||||
return (string) $value;
|
||||
}
|
||||
};
|
||||
|
||||
$formatMoneyEu = static function ($value): string {
|
||||
if ($value === null || $value === '') {
|
||||
return '';
|
||||
}
|
||||
$num = null;
|
||||
if (is_numeric($value)) {
|
||||
$num = (float) $value;
|
||||
} elseif (is_string($value)) {
|
||||
// Try to normalize string numbers like "1,234.56" or "1.234,56"
|
||||
$normalized = str_replace([' ', '\u{00A0}'], '', $value);
|
||||
$normalized = str_replace(['.', ','], ['.', '.'], $normalized);
|
||||
$num = is_numeric($normalized) ? (float) $normalized : null;
|
||||
}
|
||||
if ($num === null) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return number_format($num, 2, ',', '.').' €';
|
||||
};
|
||||
|
||||
$out = [];
|
||||
if (isset($ctx['client'])) {
|
||||
$c = $ctx['client'];
|
||||
$out['client'] = [
|
||||
'id' => data_get($c, 'id'),
|
||||
'uuid' => data_get($c, 'uuid'),
|
||||
// Expose nested person for {{ client.person.full_name }} etc.
|
||||
'person' => [
|
||||
'first_name' => data_get($c, 'person.first_name'),
|
||||
'last_name' => data_get($c, 'person.last_name'),
|
||||
'full_name' => (function ($c) {
|
||||
$fn = (string) data_get($c, 'person.first_name', '');
|
||||
$ln = (string) data_get($c, 'person.last_name', '');
|
||||
$stored = data_get($c, 'person.full_name');
|
||||
|
||||
return (string) ($stored ?: trim(trim($fn.' '.$ln)));
|
||||
})($c),
|
||||
'email' => data_get($c, 'person.email'),
|
||||
'phone' => data_get($c, 'person.phone'),
|
||||
],
|
||||
];
|
||||
}
|
||||
if (isset($ctx['person'])) {
|
||||
|
|
@ -68,6 +120,23 @@ protected function buildMap(array $ctx): array
|
|||
'id' => data_get($c, 'id'),
|
||||
'uuid' => data_get($c, 'uuid'),
|
||||
'reference' => data_get($c, 'reference'),
|
||||
// Expose nested person for {{ case.person.full_name }}; prefer direct relation, fallback to client.person
|
||||
'person' => [
|
||||
'first_name' => data_get($c, 'person.first_name') ?? data_get($c, 'client.person.first_name'),
|
||||
'last_name' => data_get($c, 'person.last_name') ?? data_get($c, 'client.person.last_name'),
|
||||
'full_name' => (function ($c) {
|
||||
$stored = data_get($c, 'person.full_name') ?? data_get($c, 'client.person.full_name');
|
||||
if ($stored) {
|
||||
return (string) $stored;
|
||||
}
|
||||
$fn = (string) (data_get($c, 'person.first_name') ?? data_get($c, 'client.person.first_name') ?? '');
|
||||
$ln = (string) (data_get($c, 'person.last_name') ?? data_get($c, 'client.person.last_name') ?? '');
|
||||
|
||||
return trim(trim($fn.' '.$ln));
|
||||
})($c),
|
||||
'email' => data_get($c, 'person.email') ?? data_get($c, 'client.person.email'),
|
||||
'phone' => data_get($c, 'person.phone') ?? data_get($c, 'client.person.phone'),
|
||||
],
|
||||
];
|
||||
}
|
||||
if (isset($ctx['contract'])) {
|
||||
|
|
@ -76,13 +145,30 @@ protected function buildMap(array $ctx): array
|
|||
'id' => data_get($co, 'id'),
|
||||
'uuid' => data_get($co, 'uuid'),
|
||||
'reference' => data_get($co, 'reference'),
|
||||
'amount' => data_get($co, 'amount'),
|
||||
// Format amounts in EU style for emails
|
||||
'amount' => $formatMoneyEu(data_get($co, 'amount')),
|
||||
];
|
||||
$meta = data_get($co, 'meta');
|
||||
if (is_array($meta)) {
|
||||
$out['contract']['meta'] = $meta;
|
||||
}
|
||||
}
|
||||
if (isset($ctx['activity'])) {
|
||||
$a = $ctx['activity'];
|
||||
$out['activity'] = [
|
||||
'id' => data_get($a, 'id'),
|
||||
'note' => data_get($a, 'note'),
|
||||
// EU formatted date and amount by default in emails
|
||||
'due_date' => $formatDateEu(data_get($a, 'due_date')),
|
||||
'amount' => $formatMoneyEu(data_get($a, 'amount')),
|
||||
'action' => [
|
||||
'name' => data_get($a, 'action.name'),
|
||||
],
|
||||
'decision' => [
|
||||
'name' => data_get($a, 'decision.name'),
|
||||
],
|
||||
];
|
||||
}
|
||||
if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
|
||||
$out['extra'] = $ctx['extra'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('email_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->foreignId('template_id')->nullable()->constrained('email_templates')->nullOnDelete();
|
||||
$table->foreignId('mail_profile_id')->nullable()->constrained('mail_profiles')->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->string('message_id')->nullable()->unique();
|
||||
$table->string('correlation_id', 100)->nullable();
|
||||
|
||||
$table->string('to_email');
|
||||
$table->string('to_name')->nullable();
|
||||
$table->json('cc')->nullable();
|
||||
$table->json('bcc')->nullable();
|
||||
$table->string('from_email')->nullable();
|
||||
$table->string('from_name')->nullable();
|
||||
$table->string('reply_to')->nullable();
|
||||
|
||||
$table->string('subject', 512);
|
||||
$table->string('body_html_hash', 64)->nullable();
|
||||
$table->text('body_text_preview')->nullable();
|
||||
$table->json('attachments')->nullable();
|
||||
$table->string('embed_mode', 16)->default('base64');
|
||||
|
||||
$table->string('status', 20)->index();
|
||||
$table->string('error_code', 100)->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->string('transport')->nullable();
|
||||
$table->json('headers')->nullable();
|
||||
$table->unsignedSmallInteger('attempt')->default(1);
|
||||
$table->unsignedInteger('duration_ms')->nullable();
|
||||
|
||||
$table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
|
||||
$table->foreignId('client_case_id')->nullable()->constrained('client_cases')->nullOnDelete();
|
||||
$table->foreignId('contract_id')->nullable()->constrained('contracts')->nullOnDelete();
|
||||
$table->json('extra_context')->nullable();
|
||||
|
||||
$table->timestamp('queued_at')->nullable();
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->timestamp('failed_at')->nullable();
|
||||
$table->string('ip', 45)->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['template_id', 'created_at']);
|
||||
$table->index(['to_email', 'created_at']);
|
||||
$table->index(['status', 'created_at']);
|
||||
$table->index(['client_id', 'client_case_id', 'contract_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('email_logs');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('email_log_bodies', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('email_log_id')->unique()->constrained('email_logs')->cascadeOnDelete();
|
||||
$table->longText('body_html')->nullable();
|
||||
$table->longText('body_text')->nullable();
|
||||
$table->boolean('inline_css')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('email_log_bodies');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('decisions', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('decisions', 'auto_mail')) {
|
||||
$table->boolean('auto_mail')->default(false)->after('color_tag');
|
||||
}
|
||||
if (! Schema::hasColumn('decisions', 'email_template_id')) {
|
||||
$table->foreignId('email_template_id')->nullable()->after('auto_mail')->constrained('email_templates')->nullOnDelete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('decisions', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('decisions', 'email_template_id')) {
|
||||
$table->dropConstrainedForeignId('email_template_id');
|
||||
}
|
||||
if (Schema::hasColumn('decisions', 'auto_mail')) {
|
||||
$table->dropColumn('auto_mail');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('email_logs', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('email_logs', 'to_recipients')) {
|
||||
$table->json('to_recipients')->nullable()->after('to_email');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('emails', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('emails', 'receive_auto_mails')) {
|
||||
$table->boolean('receive_auto_mails')->default(false)->after('is_active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('email_logs', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('email_logs', 'to_recipients')) {
|
||||
$table->dropColumn('to_recipients');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('emails', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('emails', 'receive_auto_mails')) {
|
||||
$table->dropColumn('receive_auto_mails');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import InputLabel from './InputLabel.vue';
|
||||
import SectionTitle from './SectionTitle.vue';
|
||||
import TextInput from './TextInput.vue';
|
||||
import InputError from './InputError.vue';
|
||||
import PrimaryButton from './PrimaryButton.vue';
|
||||
import axios from 'axios';
|
||||
import { computed, watch } from "vue";
|
||||
import DialogModal from "./DialogModal.vue";
|
||||
import InputLabel from "./InputLabel.vue";
|
||||
import SectionTitle from "./SectionTitle.vue";
|
||||
import TextInput from "./TextInput.vue";
|
||||
import InputError from "./InputError.vue";
|
||||
import PrimaryButton from "./PrimaryButton.vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
|
||||
/*
|
||||
EmailCreateForm / Email editor
|
||||
|
|
@ -18,70 +18,77 @@ import axios from 'axios';
|
|||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: { type: Object, required: true },
|
||||
// kept for parity with other *CreateForm components; not used directly here
|
||||
types: { type: Array, default: () => [] },
|
||||
edit: { type: Boolean, default: false },
|
||||
id: { type: Number, default: 0 },
|
||||
// When true, force-show the auto mail opt-in even if person.client wasn't eager loaded
|
||||
isClientContext: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const processing = ref(false);
|
||||
const errors = ref({});
|
||||
// Inertia useForm handles processing and errors for us
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
setTimeout(() => { errors.value = {}; }, 300);
|
||||
emit("close");
|
||||
// Clear validation errors and reset minimal fields after closing so the form reopens cleanly
|
||||
setTimeout(() => {
|
||||
form.clearErrors();
|
||||
form.reset("value", "label", "receive_auto_mails");
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const form = ref({
|
||||
value: '',
|
||||
label: ''
|
||||
const form = useForm({
|
||||
value: "",
|
||||
label: "",
|
||||
receive_auto_mails: false,
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = { value: '', label: '' };
|
||||
form.reset("value", "label", "receive_auto_mails");
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true; errors.value = {};
|
||||
try {
|
||||
const { data } = await axios.post(route('person.email.create', props.person), form.value);
|
||||
if (!Array.isArray(props.person.emails)) props.person.emails = [];
|
||||
props.person.emails.push(data.email);
|
||||
processing.value = false; close(); resetForm();
|
||||
} catch (e) {
|
||||
errors.value = e?.response?.data?.errors || {}; processing.value = false;
|
||||
}
|
||||
form.post(route("person.email.create", props.person), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true; errors.value = {};
|
||||
try {
|
||||
const { data } = await axios.put(route('person.email.update', { person: props.person, email_id: props.id }), form.value);
|
||||
if (!Array.isArray(props.person.emails)) props.person.emails = [];
|
||||
const idx = props.person.emails.findIndex(e => e.id === data.email.id);
|
||||
if (idx !== -1) props.person.emails[idx] = data.email;
|
||||
processing.value = false; close(); resetForm();
|
||||
} catch (e) {
|
||||
errors.value = e?.response?.data?.errors || {}; processing.value = false;
|
||||
}
|
||||
form.put(route("person.email.update", { person: props.person, email_id: props.id }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
(id) => {
|
||||
if (props.edit && id) {
|
||||
const current = (props.person.emails || []).find(e => e.id === id);
|
||||
if (current) {
|
||||
form.value = {
|
||||
value: current.value || current.email || current.address || '',
|
||||
label: current.label || ''
|
||||
};
|
||||
return;
|
||||
}
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
},
|
||||
{ immediate: true }
|
||||
if (props.edit && props.id) {
|
||||
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
|
||||
const email = list.find((e) => e.id === props.id);
|
||||
if (email) {
|
||||
form.value = email.value ?? email.email ?? email.address ?? "";
|
||||
form.label = email.label ?? "";
|
||||
form.receive_auto_mails = !!email.receive_auto_mails;
|
||||
} else {
|
||||
form.reset("value", "label", "receive_auto_mails");
|
||||
}
|
||||
} else {
|
||||
form.reset("value", "label", "receive_auto_mails");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const submit = () => (props.edit ? update() : create());
|
||||
|
|
@ -101,18 +108,59 @@ const submit = () => (props.edit ? update() : create());
|
|||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="em_value" value="E-pošta" />
|
||||
<TextInput id="em_value" v-model="form.value" type="email" class="mt-1 block w-full" autocomplete="email" />
|
||||
<InputError v-if="errors.value" v-for="err in errors.value" :key="err" :message="err" />
|
||||
<TextInput
|
||||
id="em_value"
|
||||
v-model="form.value"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<InputError
|
||||
v-if="form.errors.value"
|
||||
v-for="err in [].concat(form.errors.value || [])"
|
||||
:key="err"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="em_label" value="Oznaka (neobvezno)" />
|
||||
<TextInput id="em_label" v-model="form.label" type="text" class="mt-1 block w-full" autocomplete="off" />
|
||||
<InputError v-if="errors.label" v-for="err in errors.label" :key="err" :message="err" />
|
||||
<TextInput
|
||||
id="em_label"
|
||||
v-model="form.label"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<InputError
|
||||
v-if="form.errors.label"
|
||||
v-for="err in [].concat(form.errors.label || [])"
|
||||
:key="err"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.person?.client || isClientContext"
|
||||
class="mt-3 flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
id="em_receive_auto_mails"
|
||||
type="checkbox"
|
||||
v-model="form.receive_auto_mails"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<label for="em_receive_auto_mails" class="text-sm"
|
||||
>Prejemaj samodejna e-sporočila</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing">Shrani</PrimaryButton>
|
||||
<PrimaryButton
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
>Shrani</PrimaryButton
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,27 @@
|
|||
<script setup>
|
||||
// This component reuses EmailCreateForm's logic via props.edit=true
|
||||
import EmailCreateForm from './EmailCreateForm.vue';
|
||||
import EmailCreateForm from "./EmailCreateForm.vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: { type: Object, required: true },
|
||||
types: { type: Array, default: () => [] },
|
||||
id: { type: Number, default: 0 },
|
||||
// Pass-through to show the auto-mail checkbox for clients
|
||||
isClientContext: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmailCreateForm :show="show" :person="person" :types="types" :edit="true" :id="id" @close="$emit('close')" />
|
||||
<EmailCreateForm
|
||||
:show="show"
|
||||
:person="person"
|
||||
:types="types"
|
||||
:edit="true"
|
||||
:id="id"
|
||||
:is-client-context="isClientContext"
|
||||
@close="emit('close')"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ const getTRRs = (p) => {
|
|||
@close="drawerAddEmail = false"
|
||||
:person="person"
|
||||
:types="types.email_types ?? []"
|
||||
:is-client-context="!!person?.client"
|
||||
/>
|
||||
<EmailUpdateForm
|
||||
:show="drawerAddEmail && editEmail"
|
||||
|
|
@ -357,6 +358,7 @@ const getTRRs = (p) => {
|
|||
:person="person"
|
||||
:types="types.email_types ?? []"
|
||||
:id="editEmailId"
|
||||
:is-client-context="!!person?.client"
|
||||
/>
|
||||
|
||||
<!-- TRR dialogs -->
|
||||
|
|
|
|||
|
|
@ -108,6 +108,13 @@ const navGroups = computed(() => [
|
|||
"admin.email-templates.edit",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "admin.email-logs.index",
|
||||
label: "Email dnevniki",
|
||||
route: "admin.email-logs.index",
|
||||
icon: faEnvelope,
|
||||
active: ["admin.email-logs.index", "admin.email-logs.show"],
|
||||
},
|
||||
{
|
||||
key: "admin.mail-profiles.index",
|
||||
label: "Mail profili",
|
||||
|
|
|
|||
102
resources/js/Pages/Admin/EmailLogs/Index.vue
Normal file
102
resources/js/Pages/Admin/EmailLogs/Index.vue
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<script setup>
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { Head, Link, router } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
logs: Object,
|
||||
templates: Array,
|
||||
filters: Object,
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
status: props.filters?.status || '',
|
||||
to: props.filters?.to || '',
|
||||
subject: props.filters?.subject || '',
|
||||
template_id: props.filters?.template_id || '',
|
||||
date_from: props.filters?.date_from || '',
|
||||
date_to: props.filters?.date_to || '',
|
||||
})
|
||||
|
||||
function applyFilters() {
|
||||
router.get(route('admin.email-logs.index'), {
|
||||
...form.value,
|
||||
}, { preserveState: true, preserveScroll: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="Email Logs">
|
||||
<Head title="Email Logs" />
|
||||
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl font-semibold text-gray-800">Email Logs</h1>
|
||||
<div class="mt-3 grid grid-cols-1 md:grid-cols-6 gap-2">
|
||||
<select v-model="form.status" class="input">
|
||||
<option value="">All statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="sending">Sending</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="deferred">Deferred</option>
|
||||
</select>
|
||||
<input v-model="form.to" placeholder="To email" class="input" />
|
||||
<input v-model="form.subject" placeholder="Subject" class="input" />
|
||||
<select v-model="form.template_id" class="input">
|
||||
<option value="">All templates</option>
|
||||
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<input v-model="form.date_from" type="date" class="input" />
|
||||
<input v-model="form.date_to" type="date" class="input" />
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button @click="applyFilters" class="px-3 py-1.5 text-xs rounded border bg-gray-50 hover:bg-gray-100">Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm">
|
||||
<div class="overflow-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th class="text-left p-2">Date</th>
|
||||
<th class="text-left p-2">Status</th>
|
||||
<th class="text-left p-2">To</th>
|
||||
<th class="text-left p-2">Subject</th>
|
||||
<th class="text-left p-2">Template</th>
|
||||
<th class="text-left p-2">Duration</th>
|
||||
<th class="text-left p-2">\#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs.data" :key="log.id" class="border-t">
|
||||
<td class="p-2 whitespace-nowrap">{{ new Date(log.created_at).toLocaleString() }}</td>
|
||||
<td class="p-2"><span class="inline-flex items-center px-2 py-0.5 rounded text-xs border" :class="{
|
||||
'bg-green-50 text-green-700 border-green-200': log.status === 'sent',
|
||||
'bg-amber-50 text-amber-700 border-amber-200': log.status === 'queued' || log.status === 'sending',
|
||||
'bg-red-50 text-red-700 border-red-200': log.status === 'failed',
|
||||
}">{{ log.status }}</span></td>
|
||||
<td class="p-2 truncate max-w-[220px]">{{ log.to_email }}</td>
|
||||
<td class="p-2 truncate max-w-[320px]">{{ log.subject }}</td>
|
||||
<td class="p-2 truncate max-w-[220px]">{{ log.template?.name || '-' }}</td>
|
||||
<td class="p-2">{{ log.duration_ms ? log.duration_ms + ' ms' : '-' }}</td>
|
||||
<td class="p-2"><Link :href="route('admin.email-logs.show', log.id)" class="text-indigo-600 hover:underline">Open</Link></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="p-2 border-t text-xs text-gray-600 flex items-center justify-between">
|
||||
<div>Showing {{ logs.from }}-{{ logs.to }} of {{ logs.total }}</div>
|
||||
<div class="flex gap-2">
|
||||
<Link v-for="link in logs.links" :key="link.url || link.label" :href="link.url || '#'" :class="['px-2 py-1 rounded border text-xs', { 'bg-indigo-600 text-white border-indigo-600': link.active, 'pointer-events-none opacity-50': !link.url } ]" v-html="link.label" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input { width: 100%; border-radius: 0.375rem; border: 1px solid #d1d5db; padding: 0.5rem 0.75rem; font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.input:focus { outline: 2px solid transparent; outline-offset: 2px; border-color: #6366f1; box-shadow: 0 0 0 1px #6366f1; }
|
||||
</style>
|
||||
49
resources/js/Pages/Admin/EmailLogs/Show.vue
Normal file
49
resources/js/Pages/Admin/EmailLogs/Show.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script setup>
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { Head, Link } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps({
|
||||
log: Object,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="Email Log">
|
||||
<Head title="Email Log" />
|
||||
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Link :href="route('admin.email-logs.index')" class="text-sm text-gray-600 hover:text-gray-800">Back</Link>
|
||||
<h1 class="text-xl font-semibold text-gray-800">Email Log #{{ props.log.id }}</h1>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">Created: {{ new Date(props.log.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-4 space-y-2">
|
||||
<div class="text-sm"><span class="font-semibold">Status:</span> {{ props.log.status }}</div>
|
||||
<div class="text-sm"><span class="font-semibold">To:</span> {{ props.log.to_email }} {{ props.log.to_name ? '(' + props.log.to_name + ')' : '' }}</div>
|
||||
<div class="text-sm"><span class="font-semibold">Subject:</span> {{ props.log.subject }}</div>
|
||||
<div class="text-sm"><span class="font-semibold">Template:</span> {{ props.log.template?.name || '-' }}</div>
|
||||
<div class="text-sm"><span class="font-semibold">Message ID:</span> {{ props.log.message_id || '-' }}</div>
|
||||
<div class="text-sm"><span class="font-semibold">Attempts:</span> {{ props.log.attempt }}</div>
|
||||
<div class="text-sm"><span class="font-semibold">Duration:</span> {{ props.log.duration_ms ? props.log.duration_ms + ' ms' : '-' }}</div>
|
||||
<div v-if="props.log.error_message" class="text-sm text-red-700"><span class="font-semibold">Error:</span> {{ props.log.error_message }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-4">
|
||||
<div class="label">Text</div>
|
||||
<pre class="text-xs whitespace-pre-wrap break-words">{{ props.log.body?.body_text || '' }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-4">
|
||||
<div class="label">HTML</div>
|
||||
<iframe :srcdoc="props.log.body?.body_html || ''" class="w-full h-[480px] border rounded bg-white"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.label { display:block; font-size: 0.7rem; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:#6b7280; margin-bottom:0.25rem; }
|
||||
</style>
|
||||
|
|
@ -4,12 +4,7 @@ import { Head, Link, useForm, router, usePage } from "@inertiajs/vue3";
|
|||
import { ref, watch, computed, onMounted, nextTick } from "vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faArrowLeft, faEye } from "@fortawesome/free-solid-svg-icons";
|
||||
// Ensure Quill is available before importing the wrapper component
|
||||
import Quill from "quill";
|
||||
if (typeof window !== "undefined" && !window.Quill) {
|
||||
// @ts-ignore
|
||||
window.Quill = Quill;
|
||||
}
|
||||
// Keep Quill CSS for nicer preview styling, but remove the Quill editor itself
|
||||
import "quill/dist/quill.snow.css";
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -44,9 +39,8 @@ const previewHtml = computed(() => {
|
|||
return containsDocScaffold(html) ? extractBody(html) : html;
|
||||
}
|
||||
// Fallback to local content if server preview didn't provide HTML
|
||||
return sourceMode.value
|
||||
? extractBody(form.html_template || "")
|
||||
: stripDocScaffold(form.html_template || "");
|
||||
// We only use the advanced editor now, so just show the <body> content when available
|
||||
return extractBody(form.html_template || "");
|
||||
});
|
||||
const docsRaw = ref(props.template?.documents ? [...props.template.documents] : []);
|
||||
const docs = computed(() =>
|
||||
|
|
@ -66,6 +60,12 @@ function updateLocalDoc(documentId, path, name = null, size = null) {
|
|||
docsRaw.value.splice(idx, 1, next);
|
||||
}
|
||||
}
|
||||
async function removeLocalDoc(documentId) {
|
||||
const idx = docsRaw.value.findIndex((d) => d.id === documentId);
|
||||
if (idx !== -1) {
|
||||
docsRaw.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
function formatSize(bytes) {
|
||||
if (!bytes && bytes !== 0) return "";
|
||||
const kb = bytes / 1024;
|
||||
|
|
@ -97,6 +97,7 @@ const fetchPreview = () => {
|
|||
// Always send full HTML so head/styles can be respected and inlined server-side
|
||||
html: form.html_template,
|
||||
text: form.text_template,
|
||||
activity_id: sample.value.activity_id || undefined,
|
||||
client_id: sample.value.client_id || undefined,
|
||||
case_id: sample.value.case_id || undefined,
|
||||
contract_id: sample.value.contract_id || undefined,
|
||||
|
|
@ -130,6 +131,7 @@ async function fetchFinalHtml() {
|
|||
subject: form.subject_template,
|
||||
html: form.html_template,
|
||||
text: form.text_template,
|
||||
activity_id: sample.value.activity_id || undefined,
|
||||
client_id: sample.value.client_id || undefined,
|
||||
case_id: sample.value.case_id || undefined,
|
||||
contract_id: sample.value.contract_id || undefined,
|
||||
|
|
@ -186,62 +188,25 @@ onMounted(() => {
|
|||
// Populate cascading selects immediately so the Client dropdown isn't empty
|
||||
loadClients();
|
||||
fetchPreview();
|
||||
// Mount Quill editor directly
|
||||
try {
|
||||
if (quillContainer.value) {
|
||||
// instantiate lazily to ensure DOM is ready
|
||||
const editor = new Quill(quillContainer.value, {
|
||||
theme: "snow",
|
||||
modules: quillModules.value,
|
||||
});
|
||||
quill.value = editor;
|
||||
if (form.html_template) {
|
||||
const bodyOnly = stripDocScaffold(form.html_template);
|
||||
if (bodyOnly) {
|
||||
// Ensure Quill properly converts HTML to Delta
|
||||
editor.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api");
|
||||
} else {
|
||||
editor.setText(
|
||||
"(Body is empty. Switch to Source HTML to edit full document.)",
|
||||
"api"
|
||||
);
|
||||
}
|
||||
}
|
||||
editor.on("text-change", (_delta, _old, source) => {
|
||||
// Ignore programmatic changes; only persist user edits
|
||||
if (source !== "user") {
|
||||
return;
|
||||
}
|
||||
if (!sourceMode.value) {
|
||||
const bodyHtml = editor.root.innerHTML;
|
||||
form.html_template = containsDocScaffold(form.html_template)
|
||||
? replaceBody(form.html_template, bodyHtml)
|
||||
: bodyHtml;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to mount Quill", e);
|
||||
}
|
||||
// Initialize iframe editor if advanced mode is on
|
||||
if (advancedMode.value) {
|
||||
initIframeEditor();
|
||||
}
|
||||
// Advanced editor is the only mode
|
||||
activeField.value = "html";
|
||||
initIframeEditor();
|
||||
});
|
||||
|
||||
// --- Variable insertion and sample entity selection ---
|
||||
const subjectRef = ref(null);
|
||||
const quillContainer = ref(null);
|
||||
const htmlSourceRef = ref(null);
|
||||
const textRef = ref(null);
|
||||
const activeField = ref(null); // 'subject' | 'html' | 'text'
|
||||
const quill = ref(null);
|
||||
const sourceMode = ref(false); // toggle for HTML source editing
|
||||
const activeField = ref("html"); // default to HTML for variable inserts
|
||||
// Raw HTML editor toggle (full-document source)
|
||||
const rawMode = ref(false);
|
||||
// Advanced full-document editor that renders styles from <head>
|
||||
const advancedMode = ref(false);
|
||||
const advancedMode = ref(true);
|
||||
const iframeRef = ref(null);
|
||||
let iframeSyncing = false;
|
||||
const selectedImageSrc = ref("");
|
||||
// Preview iframe ref (to render preview HTML with <head> styles applied)
|
||||
const previewIframeRef = ref(null);
|
||||
|
||||
// Detect and handle full-document HTML so Quill doesn't wipe content
|
||||
function containsDocScaffold(html) {
|
||||
|
|
@ -261,20 +226,9 @@ function extractBody(html) {
|
|||
return m ? m[1] : html;
|
||||
}
|
||||
|
||||
// Retained for compatibility, but no longer used actively
|
||||
function stripDocScaffold(html) {
|
||||
if (!html) return "";
|
||||
let out = html;
|
||||
// Prefer body content when present
|
||||
out = extractBody(out);
|
||||
// Remove comments, doctype, html/head blocks, meta/title, and styles (Quill can't keep these)
|
||||
out = out
|
||||
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
|
||||
.replace(/<\/?html[^>]*>/gi, "")
|
||||
.replace(/<head[\s\S]*?>[\s\S]*?<\/head>/gi, "")
|
||||
.replace(/<\/?meta[^>]*>/gi, "")
|
||||
.replace(/<title[\s\S]*?>[\s\S]*?<\/title>/gi, "")
|
||||
.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, "");
|
||||
return out.trim();
|
||||
return extractBody(html || "");
|
||||
}
|
||||
|
||||
// Replace only the inner content of <body>...</body> in a full document
|
||||
|
|
@ -287,82 +241,31 @@ function replaceBody(htmlDoc, newBody) {
|
|||
return htmlDoc.replace(/(<body[^>]*>)[\s\S]*?(<\/body>)/i, `$1${newBody || ""}$2`);
|
||||
}
|
||||
|
||||
// Keep Quill and textarea in sync when toggling source mode
|
||||
watch(
|
||||
() => sourceMode.value,
|
||||
(on) => {
|
||||
if (on) {
|
||||
// Switching to source view: if current model is NOT a full document,
|
||||
// sync from Quill. Otherwise, keep the full source untouched.
|
||||
if (quill.value && !containsDocScaffold(form.html_template)) {
|
||||
form.html_template = quill.value.root.innerHTML;
|
||||
}
|
||||
} else {
|
||||
// switching back to WYSIWYG: update editor html from model
|
||||
if (quill.value) {
|
||||
const bodyOnly = stripDocScaffold(form.html_template || "");
|
||||
if (bodyOnly) {
|
||||
quill.value.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api");
|
||||
} else {
|
||||
quill.value.setText(
|
||||
"(Body is empty. Switch to Source HTML to edit full document.)",
|
||||
"api"
|
||||
);
|
||||
}
|
||||
} else if (quillContainer.value) {
|
||||
// if the instance doesn't exist for any reason, re-create it
|
||||
const editor = new Quill(quillContainer.value, {
|
||||
theme: "snow",
|
||||
modules: quillModules.value,
|
||||
});
|
||||
quill.value = editor;
|
||||
const bodyOnly = stripDocScaffold(form.html_template || "");
|
||||
if (bodyOnly) {
|
||||
editor.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api");
|
||||
} else {
|
||||
editor.setText(
|
||||
"(Body is empty. Switch to Source HTML to edit full document.)",
|
||||
"api"
|
||||
);
|
||||
}
|
||||
editor.on("text-change", (_d, _o, source) => {
|
||||
if (source !== "user") {
|
||||
return;
|
||||
}
|
||||
if (!sourceMode.value) {
|
||||
const bodyHtml = editor.root.innerHTML;
|
||||
form.html_template = containsDocScaffold(form.html_template)
|
||||
? replaceBody(form.html_template, bodyHtml)
|
||||
: bodyHtml;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
// Quill/source mode removed
|
||||
|
||||
// Keep advanced editor in a stable state with source/quill
|
||||
watch(
|
||||
() => advancedMode.value,
|
||||
async (on) => {
|
||||
if (on) {
|
||||
sourceMode.value = false;
|
||||
await nextTick();
|
||||
initIframeEditor();
|
||||
}
|
||||
}
|
||||
);
|
||||
// Advanced mode is always on
|
||||
|
||||
// When HTML changes externally, reflect it into iframe (unless we're the ones syncing)
|
||||
watch(
|
||||
() => form.html_template,
|
||||
async () => {
|
||||
if (!advancedMode.value || iframeSyncing) return;
|
||||
if (iframeSyncing) return;
|
||||
await nextTick();
|
||||
writeIframeDocument();
|
||||
}
|
||||
);
|
||||
|
||||
// Re-initialize iframe editor when switching back from Raw HTML
|
||||
watch(
|
||||
() => rawMode.value,
|
||||
async (on) => {
|
||||
if (!on) {
|
||||
await nextTick();
|
||||
initIframeEditor();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function ensureFullDoc(html) {
|
||||
if (!html)
|
||||
return '<!doctype html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /></head><body></body></html>';
|
||||
|
|
@ -401,6 +304,8 @@ function initIframeEditor() {
|
|||
} else {
|
||||
selectedImageSrc.value = "";
|
||||
}
|
||||
// Ensure variable buttons target HTML editor
|
||||
activeField.value = "html";
|
||||
});
|
||||
const handler = debounce(() => {
|
||||
if (!advancedMode.value) return;
|
||||
|
|
@ -416,6 +321,17 @@ function initIframeEditor() {
|
|||
doc.addEventListener("keyup", handler);
|
||||
}
|
||||
|
||||
function writePreviewDocument() {
|
||||
const iframe = previewIframeRef.value;
|
||||
if (!iframe) return;
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) return;
|
||||
const html = ensureFullDoc(preview.value?.html || form.html_template || "");
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
}
|
||||
|
||||
function iframeExec(command, value = null) {
|
||||
const iframe = iframeRef.value;
|
||||
if (!iframe) return;
|
||||
|
|
@ -522,6 +438,128 @@ function setActive(field) {
|
|||
activeField.value = field;
|
||||
}
|
||||
|
||||
async function deleteAttachedImage(doc) {
|
||||
if (!props.template?.id) return;
|
||||
if (!doc?.id) return;
|
||||
const confirmed = window.confirm(
|
||||
"Odstranim to sliko iz predloge? Datoteka bo izbrisana."
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await window.axios.delete(
|
||||
route("admin.email-templates.images.delete", {
|
||||
emailTemplate: props.template.id,
|
||||
document: doc.id,
|
||||
})
|
||||
);
|
||||
await removeLocalDoc(doc.id);
|
||||
// After deletion, scrub or replace references in current HTML
|
||||
tryReplaceOrRemoveDeletedImageReferences(doc);
|
||||
} catch (e) {
|
||||
console.error("Delete image failed", e);
|
||||
alert("Brisanje slike ni uspelo.");
|
||||
}
|
||||
}
|
||||
|
||||
function tryReplaceOrRemoveDeletedImageReferences(deletedDoc) {
|
||||
const deletedPath = deletedDoc?.path || "";
|
||||
if (!deletedPath) return;
|
||||
const targetRel = "/storage/" + deletedPath.replace(/^\/+/, "");
|
||||
|
||||
// Helper: does an <img> src match the deleted doc path (relative or absolute)?
|
||||
const srcMatches = (src) => {
|
||||
if (!src) return false;
|
||||
try {
|
||||
// Absolute URL: compare its pathname
|
||||
const u = new URL(src, window.location.origin);
|
||||
return u.pathname === targetRel;
|
||||
} catch {
|
||||
// Relative path string
|
||||
if (src === targetRel) return true;
|
||||
// Also accept raw disk path variant (unlikely in HTML)
|
||||
if (src.replace(/^\/+/, "") === targetRel.replace(/^\/+/, "")) return true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Choose a replacement doc based on <img alt> when possible; else, if only one image remains, use that.
|
||||
const pickReplacement = (altText) => {
|
||||
const remaining = (docsRaw.value || []).slice();
|
||||
if (!remaining.length) return null;
|
||||
const norm = (s) => (s || "").toString().toLowerCase();
|
||||
const stem = (name) =>
|
||||
(name || "")
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.replace(/\.[^.]+$/, "");
|
||||
const simplify = (s) => norm(s).replace(/[^a-z0-9]+/g, "");
|
||||
|
||||
if (altText) {
|
||||
const altKey = simplify(altText);
|
||||
// exact name stem match (name, file_name, original_name)
|
||||
const exact = remaining.find((d) => {
|
||||
const candidates = [d.name, d.file_name, d.original_name].map(stem);
|
||||
return candidates.some(
|
||||
(c) => simplify(c) === altKey || norm(c) === norm(altText)
|
||||
);
|
||||
});
|
||||
if (exact) return exact;
|
||||
// relaxed contains on simplified stems
|
||||
const relaxed = remaining.find((d) => {
|
||||
const candidates = [d.name, d.file_name, d.original_name].map(stem).map(simplify);
|
||||
return candidates.some((c) => c && altKey && c.includes(altKey));
|
||||
});
|
||||
if (relaxed) return relaxed;
|
||||
}
|
||||
if (remaining.length === 1) return remaining[0];
|
||||
return null;
|
||||
};
|
||||
|
||||
const replaceInDocument = (docEl) => {
|
||||
if (!docEl) return false;
|
||||
let changed = false;
|
||||
const imgs = Array.from(docEl.querySelectorAll("img"));
|
||||
imgs.forEach((img) => {
|
||||
const src = img.getAttribute("src");
|
||||
if (!srcMatches(src)) return;
|
||||
const alt = img.getAttribute("alt") || "";
|
||||
const replacement = pickReplacement(alt);
|
||||
if (replacement && replacement.path) {
|
||||
img.setAttribute("src", "/storage/" + replacement.path.replace(/^\/+/, ""));
|
||||
} else {
|
||||
// No replacement – remove the image tag entirely
|
||||
img.parentNode && img.parentNode.removeChild(img);
|
||||
}
|
||||
changed = true;
|
||||
});
|
||||
return changed;
|
||||
};
|
||||
|
||||
if (!rawMode.value) {
|
||||
// Advanced iframe editor
|
||||
const iframe = iframeRef.value;
|
||||
const doc = iframe?.contentDocument;
|
||||
if (doc && doc.documentElement) {
|
||||
const changed = replaceInDocument(doc);
|
||||
if (changed) {
|
||||
iframeSyncing = true;
|
||||
form.html_template = doc.documentElement.outerHTML;
|
||||
iframeSyncing = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Raw mode: parse and mutate via DOMParser
|
||||
const html = form.html_template || "";
|
||||
const full = ensureFullDoc(html);
|
||||
const parser = new DOMParser();
|
||||
const parsed = parser.parseFromString(full, "text/html");
|
||||
const changed = replaceInDocument(parsed);
|
||||
if (changed) {
|
||||
form.html_template = parsed.documentElement.outerHTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertAtCursor(el, value, modelGetter, modelSetter) {
|
||||
if (!el) return;
|
||||
const start = el.selectionStart ?? 0;
|
||||
|
|
@ -549,8 +587,8 @@ function insertPlaceholder(token) {
|
|||
(v) => (form.subject_template = v)
|
||||
);
|
||||
} else if (activeField.value === "html") {
|
||||
// If in source mode, treat HTML as textarea
|
||||
if (sourceMode.value && htmlSourceRef.value) {
|
||||
// If editing raw source, insert at caret into textarea
|
||||
if (rawMode.value && htmlSourceRef.value) {
|
||||
insertAtCursor(
|
||||
htmlSourceRef.value,
|
||||
content,
|
||||
|
|
@ -559,16 +597,31 @@ function insertPlaceholder(token) {
|
|||
);
|
||||
return;
|
||||
}
|
||||
// Insert into Quill editor at current selection
|
||||
if (quill.value) {
|
||||
let range = quill.value.getSelection(true);
|
||||
const index = range ? range.index : quill.value.getLength() - 1;
|
||||
quill.value.insertText(index, content, "user");
|
||||
quill.value.setSelection(index + content.length, 0, "user");
|
||||
// Sync back to form model as HTML
|
||||
form.html_template = quill.value.root.innerHTML;
|
||||
// Otherwise, insert into the iframe at caret position
|
||||
// Insert into the iframe at caret position without rewriting the document
|
||||
const iframe = iframeRef.value;
|
||||
const doc = iframe?.contentDocument;
|
||||
if (doc) {
|
||||
const sel = doc.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
const node = doc.createTextNode(content);
|
||||
range.insertNode(node);
|
||||
// place caret after inserted node
|
||||
range.setStartAfter(node);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} else {
|
||||
doc.body.appendChild(doc.createTextNode(content));
|
||||
}
|
||||
// Sync back to model
|
||||
iframeSyncing = true;
|
||||
form.html_template = doc.documentElement.outerHTML;
|
||||
iframeSyncing = false;
|
||||
} else {
|
||||
// Fallback: append to the end of the model
|
||||
// last resort
|
||||
form.html_template = (form.html_template || "") + content;
|
||||
}
|
||||
} else if (activeField.value === "text" && textRef.value) {
|
||||
|
|
@ -581,53 +634,7 @@ function insertPlaceholder(token) {
|
|||
}
|
||||
}
|
||||
|
||||
// Quill toolbar & image upload handler
|
||||
const quillModules = computed(() => ({
|
||||
toolbar: {
|
||||
container: [
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
["link", "image"],
|
||||
[{ align: [] }],
|
||||
["clean"],
|
||||
],
|
||||
handlers: {
|
||||
image: () => onQuillImageUpload(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function onQuillImageUpload() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const data = new FormData();
|
||||
data.append("file", file);
|
||||
try {
|
||||
const { data: res } = await window.axios.post(
|
||||
route("admin.email-templates.upload-image"),
|
||||
data,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
);
|
||||
const url = res?.url;
|
||||
if (url && quill.value) {
|
||||
const range = quill.value.getSelection(true);
|
||||
const index = range ? range.index : quill.value.getLength();
|
||||
quill.value.insertEmbed(index, "image", url, "user");
|
||||
quill.value.setSelection(index + 1, 0, "user");
|
||||
form.html_template = quill.value.root.innerHTML;
|
||||
}
|
||||
} catch (e) {
|
||||
// optional: show toast
|
||||
console.error("Image upload failed", e);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
// Quill handlers removed; image actions handled by iframe toolbar
|
||||
|
||||
const placeholderGroups = computed(() => {
|
||||
const groups = [];
|
||||
|
|
@ -643,10 +650,15 @@ const placeholderGroups = computed(() => {
|
|||
]);
|
||||
}
|
||||
if (want.has("client")) {
|
||||
add("client", "Client", ["client.id", "client.uuid"]);
|
||||
add("client", "Client", ["client.id", "client.uuid", "client.person.full_name"]);
|
||||
}
|
||||
if (want.has("client_case")) {
|
||||
add("case", "Case", ["case.id", "case.uuid", "case.reference"]);
|
||||
add("case", "Case", [
|
||||
"case.id",
|
||||
"case.uuid",
|
||||
"case.reference",
|
||||
"case.person.full_name",
|
||||
]);
|
||||
}
|
||||
if (want.has("contract")) {
|
||||
add("contract", "Contract", [
|
||||
|
|
@ -657,13 +669,28 @@ const placeholderGroups = computed(() => {
|
|||
"contract.meta.some_key",
|
||||
]);
|
||||
}
|
||||
// Activity placeholders (always useful if template references workflow actions/decisions)
|
||||
add("activity", "Activity", [
|
||||
"activity.id",
|
||||
"activity.note",
|
||||
"activity.due_date",
|
||||
"activity.amount",
|
||||
"activity.action.name",
|
||||
"activity.decision.name",
|
||||
]);
|
||||
// Extra is always useful for ad-hoc data
|
||||
add("extra", "Extra", ["extra.some_key"]);
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Sample entity selection for preview
|
||||
const sample = ref({ client_id: "", case_id: "", contract_id: "", extra: "" });
|
||||
const sample = ref({
|
||||
client_id: "",
|
||||
case_id: "",
|
||||
contract_id: "",
|
||||
activity_id: "",
|
||||
extra: "",
|
||||
});
|
||||
|
||||
// Cascading select options
|
||||
const clients = ref([]);
|
||||
|
|
@ -716,6 +743,10 @@ watch(
|
|||
() => sample.value.contract_id,
|
||||
() => doPreview()
|
||||
);
|
||||
watch(
|
||||
() => sample.value.activity_id,
|
||||
() => doPreview()
|
||||
);
|
||||
|
||||
function applySample() {
|
||||
fetchPreview();
|
||||
|
|
@ -745,6 +776,7 @@ function sendTest() {
|
|||
subject: form.subject_template,
|
||||
html: form.html_template,
|
||||
text: form.text_template,
|
||||
activity_id: sample.value.activity_id || undefined,
|
||||
client_id: sample.value.client_id || undefined,
|
||||
person_id: sample.value.person_id || undefined,
|
||||
case_id: sample.value.case_id || undefined,
|
||||
|
|
@ -780,6 +812,8 @@ function sendTest() {
|
|||
}
|
||||
} catch {}
|
||||
notify("Testni e-poštni naslov je bil poslan.", "success");
|
||||
// Slight UX tweak: controller now queues the email
|
||||
// (flash success message from backend reflects 'queued')
|
||||
},
|
||||
onError: () => {
|
||||
// Validation errors trigger onError; controller may also set flash('error') on redirect
|
||||
|
|
@ -798,6 +832,15 @@ function sendTest() {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Keep preview iframe in sync with server-rendered preview
|
||||
watch(
|
||||
() => preview.value?.html,
|
||||
async () => {
|
||||
await nextTick();
|
||||
writePreviewDocument();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -887,35 +930,22 @@ function sendTest() {
|
|||
<label class="label">HTML vsebina</label>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-600">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" v-model="advancedMode" /> Napredni (poln
|
||||
dokument)
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" v-model="sourceMode" :disabled="advancedMode" />
|
||||
Source HTML
|
||||
<input type="checkbox" v-model="rawMode" /> Raw HTML
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quill body editor (hidden when advanced editor is active) -->
|
||||
<div v-show="!sourceMode && !advancedMode">
|
||||
<div
|
||||
ref="quillContainer"
|
||||
class="bg-white border rounded-lg min-h-[260px] p-2 focus-within:ring-2 ring-indigo-500/40"
|
||||
@focusin="setActive('html')"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Source HTML textarea -->
|
||||
<div v-show="sourceMode && !advancedMode">
|
||||
<!-- Raw HTML textarea -->
|
||||
<div v-show="rawMode">
|
||||
<textarea
|
||||
v-model="form.html_template"
|
||||
rows="12"
|
||||
rows="16"
|
||||
class="input font-mono"
|
||||
ref="htmlSourceRef"
|
||||
@focus="setActive('html')"
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- Advanced full-document editor (iframe) -->
|
||||
<div v-show="advancedMode" class="space-y-2">
|
||||
<div v-show="!rawMode" class="space-y-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1057,6 +1087,15 @@ function sendTest() {
|
|||
<div>
|
||||
<div class="label">Sample entities</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="text-xs text-gray-600">
|
||||
Activity ID
|
||||
<input
|
||||
v-model="sample.activity_id"
|
||||
type="number"
|
||||
class="input h-9"
|
||||
placeholder="npr. 123"
|
||||
/>
|
||||
</label>
|
||||
<label class="text-xs text-gray-600">
|
||||
Client
|
||||
<select v-model="sample.client_id" class="input h-9">
|
||||
|
|
@ -1121,10 +1160,10 @@ function sendTest() {
|
|||
</div>
|
||||
<div>
|
||||
<div class="label">HTML</div>
|
||||
<div
|
||||
class="p-3 rounded-lg bg-gray-50 border ql-editor max-h-[480px] overflow-auto"
|
||||
v-html="previewHtml"
|
||||
></div>
|
||||
<iframe
|
||||
ref="previewIframeRef"
|
||||
class="w-full h-[480px] border rounded bg-white"
|
||||
></iframe>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Text</div>
|
||||
|
|
@ -1134,7 +1173,8 @@ function sendTest() {
|
|||
</div>
|
||||
<div class="text-xs text-gray-500" v-pre>
|
||||
Available placeholders example: {{ person.full_name }}, {{ client.uuid }},
|
||||
{{ case.reference }}, {{ contract.reference }}, {{ contract.meta.some_key }}
|
||||
{{ case.reference }}, {{ contract.reference }}, {{ contract.meta.some_key }},
|
||||
{{ activity.note }}, {{ activity.action.name }}, {{ activity.decision.name }}
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col sm:flex-row sm:items-end gap-2">
|
||||
<div class="w-full sm:w-auto">
|
||||
|
|
@ -1214,6 +1254,13 @@ function sendTest() {
|
|||
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
||||
>Odpri</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border bg-red-50 text-red-700 hover:bg-red-100"
|
||||
@click="deleteAttachedImage(d)"
|
||||
>
|
||||
Odstrani
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import TextInput from "@/Components/TextInput.vue";
|
|||
import CurrencyInput from "@/Components/CurrencyInput.vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import { FwbTextarea } from "flowbite-vue";
|
||||
import { ref, watch } from "vue";
|
||||
import { ref, watch, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
|
|
@ -38,6 +38,7 @@ const form = useForm({
|
|||
action_id: props.actions[0].id,
|
||||
decision_id: props.actions[0].decisions[0].id,
|
||||
contract_uuid: props.contractUuid,
|
||||
send_auto_mail: true,
|
||||
});
|
||||
|
||||
watch(
|
||||
|
|
@ -45,6 +46,8 @@ watch(
|
|||
(action_id) => {
|
||||
decisions.value = props.actions.filter((el) => el.id === action_id)[0].decisions;
|
||||
form.decision_id = decisions.value[0].id;
|
||||
// reset send flag on action change (will re-evaluate below)
|
||||
form.send_auto_mail = true;
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -116,6 +119,41 @@ watch(
|
|||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Helper to read metadata for the currently selected decision
|
||||
const currentDecision = () => decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0];
|
||||
const showSendAutoMail = () => {
|
||||
const d = currentDecision();
|
||||
return !!(d && d.auto_mail && d.email_template_id);
|
||||
};
|
||||
|
||||
// Determine if the selected template requires a contract
|
||||
const autoMailRequiresContract = computed(() => {
|
||||
const d = currentDecision();
|
||||
if (!d) return false;
|
||||
const tmpl = d.email_template || d.emailTemplate || null;
|
||||
const types = Array.isArray(tmpl?.entity_types) ? tmpl.entity_types : [];
|
||||
return types.includes("contract");
|
||||
});
|
||||
|
||||
// Disable checkbox when contract is required but none is selected
|
||||
const autoMailDisabled = computed(() => {
|
||||
return showSendAutoMail() && autoMailRequiresContract.value && !form.contract_uuid;
|
||||
});
|
||||
|
||||
const autoMailDisabledHint = computed(() => {
|
||||
return autoMailDisabled.value ? "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo." : "";
|
||||
});
|
||||
|
||||
// If disabled, force the flag off to avoid accidental queue attempts
|
||||
watch(
|
||||
() => autoMailDisabled.value,
|
||||
(disabled) => {
|
||||
if (disabled) {
|
||||
form.send_auto_mail = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
|
|
@ -179,6 +217,22 @@ watch(
|
|||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2" v-if="showSendAutoMail()">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.send_auto_mail"
|
||||
:disabled="autoMailDisabled"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>Send auto email</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="autoMailDisabled" class="mt-1 text-xs text-amber-600">
|
||||
{{ autoMailDisabledHint }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<ActionMessage :on="form.recentlySuccessful" class="me-3">
|
||||
Shranjuje.
|
||||
|
|
|
|||
|
|
@ -257,6 +257,7 @@ const submitAttachSegment = () => {
|
|||
:types="types"
|
||||
tab-color="red-600"
|
||||
:person="client_case.person"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import ActionMessage from '@/Components/ActionMessage.vue';
|
|||
|
||||
const props = defineProps({
|
||||
decisions: Array,
|
||||
actions: Array
|
||||
actions: Array,
|
||||
emailTemplates: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
const drawerEdit = ref(false);
|
||||
|
|
@ -22,6 +23,8 @@ const showDelete = ref(false);
|
|||
const toDelete = ref(null);
|
||||
|
||||
const search = ref('');
|
||||
const selectedTemplateId = ref(null);
|
||||
const onlyAutoMail = ref(false);
|
||||
|
||||
const actionOptions = ref([]);
|
||||
|
||||
|
|
@ -29,13 +32,17 @@ const form = useForm({
|
|||
id: 0,
|
||||
name: '',
|
||||
color_tag: '',
|
||||
actions: []
|
||||
actions: [],
|
||||
auto_mail: false,
|
||||
email_template_id: null,
|
||||
});
|
||||
|
||||
const createForm = useForm({
|
||||
name: '',
|
||||
color_tag: '',
|
||||
actions: []
|
||||
actions: [],
|
||||
auto_mail: false,
|
||||
email_template_id: null,
|
||||
});
|
||||
|
||||
const openEditDrawer = (item) => {
|
||||
|
|
@ -43,6 +50,8 @@ const openEditDrawer = (item) => {
|
|||
form.id = item.id;
|
||||
form.name = item.name;
|
||||
form.color_tag = item.color_tag;
|
||||
form.auto_mail = !!item.auto_mail;
|
||||
form.email_template_id = item.email_template_id || null;
|
||||
drawerEdit.value = true;
|
||||
|
||||
item.actions.forEach((a) => {
|
||||
|
|
@ -79,8 +88,12 @@ onMounted(() => {
|
|||
|
||||
const filtered = computed(() => {
|
||||
const term = search.value?.toLowerCase() ?? '';
|
||||
const tplId = selectedTemplateId.value ? Number(selectedTemplateId.value) : null;
|
||||
return (props.decisions || []).filter(d => {
|
||||
return !term || d.name?.toLowerCase().includes(term) || d.color_tag?.toLowerCase().includes(term);
|
||||
const matchesSearch = !term || d.name?.toLowerCase().includes(term) || d.color_tag?.toLowerCase().includes(term);
|
||||
const matchesAuto = !onlyAutoMail.value || !!d.auto_mail;
|
||||
const matchesTemplate = !tplId || Number(d.email_template_id || 0) === tplId;
|
||||
return matchesSearch && matchesAuto && matchesTemplate;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -120,8 +133,18 @@ const destroyDecision = () => {
|
|||
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-4 flex items-center justify-between gap-3">
|
||||
<TextInput v-model="search" placeholder="Search decisions..." class="w-full sm:w-72" />
|
||||
<div class="p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div class="flex flex-col sm:flex-row gap-2 items-start sm:items-center">
|
||||
<TextInput v-model="search" placeholder="Search decisions..." class="w-full sm:w-72" />
|
||||
<select v-model="selectedTemplateId" class="block w-full sm:w-64 border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||
<option :value="null">All templates</option>
|
||||
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" v-model="onlyAutoMail" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500">
|
||||
Only auto mail
|
||||
</label>
|
||||
</div>
|
||||
<PrimaryButton @click="openCreateDrawer">+ Dodaj odločitev</PrimaryButton>
|
||||
</div>
|
||||
|
||||
|
|
@ -131,6 +154,7 @@ const destroyDecision = () => {
|
|||
<fwb-table-head-cell>Name</fwb-table-head-cell>
|
||||
<fwb-table-head-cell>Color tag</fwb-table-head-cell>
|
||||
<fwb-table-head-cell>Belongs to actions</fwb-table-head-cell>
|
||||
<fwb-table-head-cell>Auto mail</fwb-table-head-cell>
|
||||
<fwb-table-head-cell>
|
||||
<span class="sr-only">Edit</span>
|
||||
</fwb-table-head-cell>
|
||||
|
|
@ -146,6 +170,14 @@ const destroyDecision = () => {
|
|||
</div>
|
||||
</fwb-table-cell>
|
||||
<fwb-table-cell>{{ d.actions.length }}</fwb-table-cell>
|
||||
<fwb-table-cell>
|
||||
<div class="flex flex-col text-sm">
|
||||
<span :class="d.auto_mail ? 'text-green-700' : 'text-gray-500'">{{ d.auto_mail ? 'Enabled' : 'Disabled' }}</span>
|
||||
<span v-if="d.auto_mail && d.email_template_id" class="text-gray-600">
|
||||
Template: {{ (emailTemplates.find(t => t.id === d.email_template_id)?.name) || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</fwb-table-cell>
|
||||
<fwb-table-cell>
|
||||
<button class="px-2" @click="openEditDrawer(d)"><EditIcon size="md" css="text-gray-500" /></button>
|
||||
<button class="px-2 disabled:opacity-40" :disabled="(d.activities_count ?? 0) > 0" @click="confirmDelete(d)"><TrashBinIcon size="md" css="text-red-500" /></button>
|
||||
|
|
@ -170,6 +202,22 @@ const destroyDecision = () => {
|
|||
autocomplete="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<input id="autoMailEdit" type="checkbox" v-model="form.auto_mail" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500">
|
||||
<label for="autoMailEdit" class="text-sm">Samodejna pošta (auto mail)</label>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4 mt-2">
|
||||
<InputLabel for="emailTemplateEdit" value="Email predloga"/>
|
||||
<select id="emailTemplateEdit" v-model="form.email_template_id" :disabled="!form.auto_mail" class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<option :value="null">— Brez —</option>
|
||||
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<p v-if="form.email_template_id" class="text-xs text-gray-500 mt-1">
|
||||
<span v-if="(emailTemplates.find(t => t.id === form.email_template_id)?.entity_types || []).includes('contract')">Ta predloga zahteva pogodbo.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="colorTag" value="Barva"/>
|
||||
<div class="mt-1 w-full border flex p-1">
|
||||
|
|
@ -228,6 +276,22 @@ const destroyDecision = () => {
|
|||
autocomplete="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<input id="autoMailCreate" type="checkbox" v-model="createForm.auto_mail" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500">
|
||||
<label for="autoMailCreate" class="text-sm">Samodejna pošta (auto mail)</label>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4 mt-2">
|
||||
<InputLabel for="emailTemplateCreate" value="Email predloga"/>
|
||||
<select id="emailTemplateCreate" v-model="createForm.email_template_id" :disabled="!createForm.auto_mail" class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<option :value="null">— Brez —</option>
|
||||
<option v-for="t in emailTemplates" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<p v-if="createForm.email_template_id" class="text-xs text-gray-500 mt-1">
|
||||
<span v-if="(emailTemplates.find(t => t.id === createForm.email_template_id)?.entity_types || []).includes('contract')">Ta predloga zahteva pogodbo.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="colorTagCreate" value="Barva"/>
|
||||
<div class="mt-1 w-full border flex p-1">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import DecisionTable from '../Partials/DecisionTable.vue';
|
|||
const props = defineProps({
|
||||
actions: Array,
|
||||
decisions: Array,
|
||||
segments: Array
|
||||
segments: Array,
|
||||
email_templates: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
const activeTab = ref('actions')
|
||||
|
|
@ -25,7 +26,7 @@ const activeTab = ref('actions')
|
|||
<ActionTable :actions="actions" :decisions="decisions" :segments="segments" />
|
||||
</fwb-tab>
|
||||
<fwb-tab name="decisions" title="Decisions">
|
||||
<DecisionTable :decisions="decisions" :actions="actions" />
|
||||
<DecisionTable :decisions="decisions" :actions="actions" :email-templates="email_templates" />
|
||||
</fwb-tab>
|
||||
</fwb-tabs>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -85,10 +85,15 @@
|
|||
Route::post('email-templates/{emailTemplate}/render-final', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'renderFinalHtml'])->name('email-templates.render-final');
|
||||
Route::post('email-templates/upload-image', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'uploadImage'])->name('email-templates.upload-image');
|
||||
Route::post('email-templates/{emailTemplate}/replace-image', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'replaceImage'])->name('email-templates.replace-image');
|
||||
Route::delete('email-templates/{emailTemplate}/images/{document}', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'deleteImage'])->name('email-templates.images.delete');
|
||||
// Cascading selects data
|
||||
Route::get('email-templates-data/clients', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'clients'])->name('email-templates.data.clients');
|
||||
Route::get('email-templates-data/clients/{client}/cases', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'casesForClient'])->name('email-templates.data.cases');
|
||||
Route::get('email-templates-data/cases/{clientCase}/contracts', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'contractsForCase'])->name('email-templates.data.contracts');
|
||||
|
||||
// Email logs
|
||||
Route::get('email-logs', [\App\Http\Controllers\Admin\EmailLogController::class, 'index'])->name('email-logs.index');
|
||||
Route::get('email-logs/{emailLog}', [\App\Http\Controllers\Admin\EmailLogController::class, 'show'])->name('email-logs.show');
|
||||
});
|
||||
|
||||
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
|
||||
|
|
|
|||
34
tests/Unit/EmailTemplateRendererNestedPersonTest.php
Normal file
34
tests/Unit/EmailTemplateRendererNestedPersonTest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Person\Person;
|
||||
use App\Services\EmailTemplateRenderer;
|
||||
|
||||
it('renders client.person.full_name and case.person.full_name', function () {
|
||||
$person = new Person(['first_name' => 'Ana', 'last_name' => 'Novak', 'full_name' => 'Ana Novak']);
|
||||
$client = new Client(['id' => 1, 'uuid' => 'c-1']);
|
||||
$client->setRelation('person', $person);
|
||||
|
||||
$case = new ClientCase(['id' => 2, 'uuid' => 'k-2', 'reference' => 'R-2']);
|
||||
$case->setRelation('client', $client);
|
||||
$case->setRelation('person', $person);
|
||||
|
||||
$renderer = app(EmailTemplateRenderer::class);
|
||||
$tpl = [
|
||||
'subject' => '{{ client.person.full_name }} vs {{ case.person.full_name }}',
|
||||
'html' => '<p>{{ client.person.full_name }} - {{ case.person.full_name }}</p>',
|
||||
'text' => '{{ client.person.full_name }}|{{ case.person.full_name }}',
|
||||
];
|
||||
$ctx = [
|
||||
'client' => $client,
|
||||
'client_case' => $case,
|
||||
'person' => $person,
|
||||
];
|
||||
|
||||
$out = $renderer->render($tpl, $ctx);
|
||||
|
||||
expect($out['subject'])->toBe('Ana Novak vs Ana Novak');
|
||||
expect($out['html'])->toContain('Ana Novak - Ana Novak');
|
||||
expect($out['text'])->toBe('Ana Novak|Ana Novak');
|
||||
});
|
||||
|
|
@ -2,12 +2,40 @@
|
|||
|
||||
use App\Services\EmailTemplateRenderer;
|
||||
|
||||
it('renders activity placeholders', function () {
|
||||
/** @var EmailTemplateRenderer $renderer */
|
||||
$renderer = app(EmailTemplateRenderer::class);
|
||||
|
||||
$activity = (object) [
|
||||
'note' => 'Call client about payment',
|
||||
'due_date' => '2025-10-15',
|
||||
'amount' => 123.45,
|
||||
'action' => (object) ['name' => 'CALL - OUTBOUND'],
|
||||
'decision' => (object) ['name' => 'Promise'],
|
||||
];
|
||||
|
||||
$tpl = [
|
||||
'subject' => 'Action: {{ activity.action.name }}',
|
||||
'html' => '<p>{{ activity.note }}</p><p>{{ activity.decision.name }} {{ activity.due_date }} {{ activity.amount }}</p>',
|
||||
'text' => 'Note: {{ activity.note }}',
|
||||
];
|
||||
|
||||
$out = $renderer->render($tpl, [
|
||||
'activity' => $activity,
|
||||
]);
|
||||
|
||||
expect($out['subject'])->toBe('Action: CALL - OUTBOUND');
|
||||
expect($out['html'])
|
||||
->toContain('<p>Call client about payment</p>')
|
||||
->toContain('<p>Promise 15.10.2025 123,45 €</p>');
|
||||
expect($out['text'])->toBe('Note: Call client about payment');
|
||||
});
|
||||
it('renders placeholders in subject, html and text', function () {
|
||||
$renderer = new EmailTemplateRenderer;
|
||||
|
||||
$template = [
|
||||
'subject' => 'Hello {{ person.full_name }} - {{ contract.reference }}',
|
||||
'html' => '<p>Case: {{ case.uuid }}</p><p>Meta: {{ contract.meta.foo }}</p>',
|
||||
'html' => '<p>Case: {{ case.uuid }}</p><p>Meta: {{ contract.meta.foo }}</p><p>Amount: {{ contract.amount }}</p>',
|
||||
'text' => 'Client: {{ client.uuid }} Extra: {{ extra.note }}',
|
||||
];
|
||||
|
||||
|
|
@ -15,7 +43,7 @@
|
|||
'person' => (object) ['first_name' => 'Jane', 'last_name' => 'Doe', 'email' => 'jane@example.test'],
|
||||
'client' => (object) ['uuid' => 'cl-123'],
|
||||
'client_case' => (object) ['uuid' => 'cc-456', 'reference' => 'REF-1'],
|
||||
'contract' => (object) ['uuid' => 'co-789', 'reference' => 'CON-42', 'meta' => ['foo' => 'bar']],
|
||||
'contract' => (object) ['uuid' => 'co-789', 'reference' => 'CON-42', 'amount' => 1000, 'meta' => ['foo' => 'bar']],
|
||||
'extra' => ['note' => 'hello'],
|
||||
];
|
||||
|
||||
|
|
@ -24,5 +52,6 @@
|
|||
expect($result['subject'])->toBe('Hello Jane Doe - CON-42');
|
||||
expect($result['html'])->toContain('Case: cc-456');
|
||||
expect($result['html'])->toContain('Meta: bar');
|
||||
expect($result['html'])->toContain('Amount: 1.000,00 €');
|
||||
expect($result['text'])->toBe('Client: cl-123 Extra: hello');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user