Decision now support auto mailing

This commit is contained in:
Simon Pocrnjič 2025-10-12 00:20:03 +02:00
parent 1b615163be
commit 3ab1c05fcc
33 changed files with 1862 additions and 548 deletions

View 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,
]);
}
}

View File

@ -4,26 +4,24 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEmailTemplateRequest; 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\Client;
use App\Models\ClientCase; use App\Models\ClientCase;
use App\Models\Contract; use App\Models\Contract;
use App\Models\Document; use App\Models\Document;
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Models\EmailTemplate; use App\Models\EmailTemplate;
use App\Models\MailProfile;
use App\Models\Person\Person;
use App\Services\EmailTemplateRenderer; use App\Services\EmailTemplateRenderer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; 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 Symfony\Component\Mime\Email;
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
@ -31,6 +29,19 @@ class EmailTemplateController extends Controller
{ {
use AuthorizesRequests; 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 public function index(): Response
{ {
$this->authorize('viewAny', EmailTemplate::class); $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'); return redirect()->route('admin.email-templates.edit', $tpl)->with('success', 'Template created');
} }
public function edit(EmailTemplate $emailTemplate): Response /**
{ * Render a quick preview of the email template with the provided context.
$this->authorize('update', $emailTemplate); * Does not persist any changes or inline CSS; intended for fast editor feedback.
$emailTemplate->load(['documents' => function ($q) { */
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']); public function preview(Request $request, EmailTemplate $emailTemplate): JsonResponse
}]);
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)
{ {
$this->authorize('view', $emailTemplate); $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); $renderer = app(EmailTemplateRenderer::class);
$subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template); $subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
$html = (string) ($request->input('html') ?? $emailTemplate->html_template); $html = (string) ($request->input('html') ?? $emailTemplate->html_template);
$text = (string) ($request->input('text') ?? $emailTemplate->text_template); $text = (string) ($request->input('text') ?? $emailTemplate->text_template);
// Adopt tmp images (tmp/email-images) so test email can display images; also persist // Do not persist tmp images for preview, but allow showing them if already accessible
$html = $this->adoptTmpImagesInHtml($emailTemplate, $html, true); // 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 = []; $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')) { if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person'])->find($id); $contract = Contract::query()->with(['clientCase.client.person'])->find($id);
if ($contract) { if ($contract) {
@ -217,107 +147,131 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
'text' => $text, 'text' => $text,
], $ctx); ], $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'); $to = (string) $request->input('to');
if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) { if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
return back()->with('error', 'Invalid target email'); return back()->with('error', 'Invalid target email');
} }
// First repair images missing src if they are followed by a URL (editor artifact) // Prepare EmailLog record with queued status
if (! empty($rendered['html'])) { $log = new EmailLog;
$rendered['html'] = $this->repairImgWithoutSrc($rendered['html']); $log->fill([
$rendered['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $rendered['html']); 'uuid' => (string) \Str::uuid(),
} 'template_id' => $emailTemplate->id,
// Embed images as requested (default hosted for Gmail compatibility) 'to_email' => $to,
$htmlForSend = $rendered['html'] ?? ''; 'to_name' => null,
$embed = (string) $request->input('embed', 'base64'); '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 // Store bodies in companion table (optional, enabled here)
$subject = $rendered['subject'] ?? ''; $log->body()->create([
$profile = MailProfile::query() 'body_html' => (string) ($rendered['html'] ?? ''),
->where('active', true) 'body_text' => (string) ($rendered['text'] ?? ''),
->orderBy('priority') 'inline_css' => true,
->orderBy('id') ]);
->first();
try { // Dispatch the queued job
if ($profile) { dispatch(new SendEmailTemplateJob($log->id));
$host = $profile->host;
$port = (int) ($profile->port ?: 587);
$encryption = $profile->encryption ?: 'tls';
$username = $profile->username ?: '';
$password = (string) ($profile->decryptPassword() ?? '');
$scheme = $encryption === 'ssl' ? 'smtps' : 'smtp'; return back()->with('success', 'Test email queued for '.$to);
$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());
}
} }
/** /**
@ -338,6 +292,22 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
// Context resolution (same as sendTest) // Context resolution (same as sendTest)
$ctx = []; $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')) { if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person'])->find($id); $contract = Contract::query()->with(['clientCase.client.person'])->find($id);
if ($contract) { 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 * 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. * location under /storage/email-images, create Document records and update the HTML.

View File

@ -126,14 +126,15 @@ public function sendTest(Request $request, MailProfile $mailProfile)
$mailer = new SymfonyMailer($transport); $mailer = new SymfonyMailer($transport);
$fromAddr = $mailProfile->from_address ?: $username; $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>'; $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().'.'; $text = 'This is a test email from profile "'.$mailProfile->name.'" at '.now()->toDateTimeString().'.';
// Build email // Build email
$fromAddress = $fromName !== '' ? new Address($fromAddr, $fromName) : new Address($fromAddr);
$email = (new Email) $email = (new Email)
->from(new Address($fromAddr, $fromName)) ->from($fromAddress)
->to($to) ->to($to)
->subject('Test email - '.$mailProfile->name) ->subject('Test email - '.$mailProfile->name)
->text($text) ->text($text)

View File

@ -21,7 +21,7 @@ class ClientCaseContoller extends Controller
public function index(ClientCase $clientCase, Request $request) public function index(ClientCase $clientCase, Request $request)
{ {
$query = $clientCase::query() $query = $clientCase::query()
->with(['person', 'client.person']) ->with(['person.client', 'client.person'])
->where('active', 1) ->where('active', 1)
->when($request->input('search'), function ($que, $search) { ->when($request->input('search'), function ($que, $search) {
$que->whereHas('person', function ($q) use ($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', 'action_id' => 'exists:\App\Models\Action,id',
'decision_id' => 'exists:\App\Models\Decision,id', 'decision_id' => 'exists:\App\Models\Decision,id',
'contract_uuid' => 'nullable|uuid', 'contract_uuid' => 'nullable|uuid',
'send_auto_mail' => 'sometimes|boolean',
]); ]);
// Map contract_uuid to contract_id within the same client case, if provided // 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); 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. // 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. // Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
return back(303)->with('success', 'Successful created!'); return back(303)->with('success', 'Successful created!');
@ -1020,7 +1038,7 @@ protected function streamDocumentForDisk(Document $document, bool $inline = true
public function show(ClientCase $clientCase) public function show(ClientCase $clientCase)
{ {
$case = $clientCase::with([ $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); ])->where('active', 1)->findOrFail($clientCase->id);
$types = [ $types = [
@ -1174,7 +1192,7 @@ public function show(ClientCase $clientCase)
} }
return Inertia::render('Cases/Show', [ 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, 'client_case' => $case,
'contracts' => $contracts, 'contracts' => $contracts,
'archive_meta' => [ 'archive_meta' => [
@ -1209,11 +1227,17 @@ function ($p) {
'documents' => $mergedDocs, 'documents' => $mergedDocs,
'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(), 'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(),
'account_types' => \App\Models\AccountType::all(), 'account_types' => \App\Models\AccountType::all(),
'actions' => \App\Models\Action::with('decisions') // Include decisions with auto-mail metadata and the linked email template entity_types for UI logic
/*->when($segmentId, function($q) use($segmentId) { 'actions' => \App\Models\Action::query()
$q->where('segment_id', $segmentId)->orWhereNull('segment_id'); ->with([
})*/ 'decisions' => function ($q) {
->get(), $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, 'types' => $types,
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']), 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']), 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),

View File

@ -63,7 +63,7 @@ public function show(Client $client, Request $request)
{ {
$data = $client::query() $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); ->findOrFail($client->id);
$types = [ $types = [

View File

@ -2,32 +2,26 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Person\Person;
use App\Models\BankAccount; use App\Models\BankAccount;
use App\Models\Person\Person;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia;
class PersonController extends Controller 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([ $attributes = $request->validate([
'full_name' => 'string|max:255', 'full_name' => 'string|max:255',
'tax_number' => 'nullable|integer', 'tax_number' => 'nullable|integer',
'social_security_number' => 'nullable|integer', 'social_security_number' => 'nullable|integer',
'description' => 'nullable|string|max:500' 'description' => 'nullable|string|max:500',
]); ]);
$person->update($attributes); $person->update($attributes);
@ -37,17 +31,18 @@ public function update(Person $person, Request $request){
'full_name' => $person->full_name, 'full_name' => $person->full_name,
'tax_number' => $person->tax_number, 'tax_number' => $person->tax_number,
'social_security_number' => $person->social_security_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([ $attributes = $request->validate([
'address' => 'required|string|max:150', 'address' => 'required|string|max:150',
'country' => 'nullable|string', 'country' => 'nullable|string',
'type_id' => 'required|integer|exists:address_types,id', '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) // Dedup: avoid duplicate address per person by (address, country)
@ -57,7 +52,7 @@ public function createAddress(Person $person, Request $request){
], $attributes); ], $attributes);
return response()->json([ 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', 'address' => 'required|string|max:150',
'country' => 'nullable|string', 'country' => 'nullable|string',
'type_id' => 'required|integer|exists:address_types,id', '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); $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); $address->update($attributes);
return response()->json([ 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 = $person->addresses()->findOrFail($address_id);
$address->delete(); // soft delete $address->delete(); // soft delete
return response()->json(['status' => 'ok']); return response()->json(['status' => 'ok']);
} }
@ -92,7 +88,7 @@ public function createPhone(Person $person, Request $request)
'nu' => 'required|string|max:50', 'nu' => 'required|string|max:50',
'country_code' => 'nullable|integer', 'country_code' => 'nullable|integer',
'type_id' => 'required|integer|exists:phone_types,id', '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) // Dedup: avoid duplicate phone per person by (nu, country_code)
@ -102,7 +98,7 @@ public function createPhone(Person $person, Request $request)
], $attributes); ], $attributes);
return response()->json([ 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', 'nu' => 'required|string|max:50',
'country_code' => 'nullable|integer', 'country_code' => 'nullable|integer',
'type_id' => 'required|integer|exists:phone_types,id', '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); $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); $phone->update($attributes);
return response()->json([ 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 = $person->phones()->findOrFail($phone_id);
$phone->delete(); // soft delete $phone->delete(); // soft delete
return response()->json(['status' => 'ok']); return response()->json(['status' => 'ok']);
} }
@ -139,6 +136,7 @@ public function createEmail(Person $person, Request $request)
'is_primary' => 'boolean', 'is_primary' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
'valid' => 'boolean', 'valid' => 'boolean',
'receive_auto_mails' => 'sometimes|boolean',
'verified_at' => 'nullable|date', 'verified_at' => 'nullable|date',
'preferences' => 'nullable|array', 'preferences' => 'nullable|array',
'meta' => 'nullable|array', 'meta' => 'nullable|array',
@ -149,9 +147,7 @@ public function createEmail(Person $person, Request $request)
'value' => $attributes['value'], 'value' => $attributes['value'],
], $attributes); ], $attributes);
return response()->json([ return back()->with('success', 'Email added successfully');
'email' => \App\Models\Email::findOrFail($email->id)
]);
} }
public function updateEmail(Person $person, int $email_id, Request $request) 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_primary' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
'valid' => 'boolean', 'valid' => 'boolean',
'receive_auto_mails' => 'sometimes|boolean',
'verified_at' => 'nullable|date', 'verified_at' => 'nullable|date',
'preferences' => 'nullable|array', 'preferences' => 'nullable|array',
'meta' => 'nullable|array', 'meta' => 'nullable|array',
@ -171,15 +168,14 @@ public function updateEmail(Person $person, int $email_id, Request $request)
$email->update($attributes); $email->update($attributes);
return response()->json([ return back()->with('success', 'Email updated successfully');
'email' => $email
]);
} }
public function deleteEmail(Person $person, int $email_id, Request $request) public function deleteEmail(Person $person, int $email_id, Request $request)
{ {
$email = $person->emails()->findOrFail($email_id); $email = $person->emails()->findOrFail($email_id);
$email->delete(); $email->delete();
return response()->json(['status' => 'ok']); return response()->json(['status' => 'ok']);
} }
@ -203,7 +199,7 @@ public function createTrr(Person $person, Request $request)
$trr = $person->bankAccounts()->create($attributes); $trr = $person->bankAccounts()->create($attributes);
return response()->json([ 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); $trr->update($attributes);
return response()->json([ 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 = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete(); $trr->delete();
return response()->json(['status' => 'ok']); return response()->json(['status' => 'ok']);
} }
} }

View File

@ -4,9 +4,9 @@
use App\Models\Action; use App\Models\Action;
use App\Models\Decision; use App\Models\Decision;
use App\Models\EmailTemplate;
use App\Models\Segment; use App\Models\Segment;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
class WorkflowController extends Controller class WorkflowController extends Controller
@ -17,6 +17,7 @@ public function index(Request $request)
'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->get(), 'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->get(),
'decisions' => Decision::query()->with('actions')->withCount('activities')->get(), 'decisions' => Decision::query()->with('actions')->withCount('activities')->get(),
'segments' => Segment::query()->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, 'segment_id' => $attributes['segment_id'] ?? null,
]); ]);
if (!empty($decisionIds)) { if (! empty($decisionIds)) {
$row->decisions()->sync($decisionIds); $row->decisions()->sync($decisionIds);
} }
}); });
@ -59,12 +60,12 @@ public function updateAction(int $id, Request $request)
'segment_id' => 'nullable|integer|exists:segments,id', 'segment_id' => 'nullable|integer|exists:segments,id',
'decisions' => 'nullable|array', 'decisions' => 'nullable|array',
'decisions.*.id' => 'required_with:decisions.*|integer|exists:decisions,id', '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(); $decisionIds = collect($attributes['decisions'] ?? [])->pluck('id')->toArray();
\DB::transaction(function() use ($attributes, $decisionIds, $row) { \DB::transaction(function () use ($attributes, $decisionIds, $row) {
$row->update([ $row->update([
'name' => $attributes['name'], 'name' => $attributes['name'],
'color_tag' => $attributes['color_tag'], 'color_tag' => $attributes['color_tag'],
@ -81,6 +82,8 @@ public function storeDecision(Request $request)
$attributes = $request->validate([ $attributes = $request->validate([
'name' => 'required|string|max:50', 'name' => 'required|string|max:50',
'color_tag' => 'nullable|string|max:25', 'color_tag' => 'nullable|string|max:25',
'auto_mail' => 'sometimes|boolean',
'email_template_id' => 'nullable|integer|exists:email_templates,id',
'actions' => 'nullable|array', 'actions' => 'nullable|array',
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id', 'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
'actions.*.name' => 'required_with:actions.*|string|max:50', 'actions.*.name' => 'required_with:actions.*|string|max:50',
@ -93,9 +96,11 @@ public function storeDecision(Request $request)
$row = Decision::create([ $row = Decision::create([
'name' => $attributes['name'], 'name' => $attributes['name'],
'color_tag' => $attributes['color_tag'] ?? null, '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); $row->actions()->sync($actionIds);
} }
}); });
@ -110,6 +115,8 @@ public function updateDecision(int $id, Request $request)
$attributes = $request->validate([ $attributes = $request->validate([
'name' => 'required|string|max:50', 'name' => 'required|string|max:50',
'color_tag' => 'nullable|string|max:25', 'color_tag' => 'nullable|string|max:25',
'auto_mail' => 'sometimes|boolean',
'email_template_id' => 'nullable|integer|exists:email_templates,id',
'actions' => 'nullable|array', 'actions' => 'nullable|array',
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id', 'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
'actions.*.name' => 'required_with:actions.*|string|max:50', 'actions.*.name' => 'required_with:actions.*|string|max:50',
@ -121,6 +128,8 @@ public function updateDecision(int $id, Request $request)
$row->update([ $row->update([
'name' => $attributes['name'], 'name' => $attributes['name'],
'color_tag' => $attributes['color_tag'] ?? null, '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); $row->actions()->sync($actionIds);
}); });
@ -139,6 +148,7 @@ public function destroyAction(int $id)
$row->decisions()->detach(); $row->decisions()->detach();
$row->delete(); $row->delete();
}); });
return back()->with('success', 'Action deleted successfully!'); return back()->with('success', 'Action deleted successfully!');
} }
@ -153,6 +163,7 @@ public function destroyDecision(int $id)
$row->actions()->detach(); $row->actions()->detach();
$row->delete(); $row->delete();
}); });
return back()->with('success', 'Decision deleted successfully!'); return back()->with('success', 'Decision deleted successfully!');
} }
} }

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

View File

@ -13,7 +13,14 @@ class Decision extends Model
/** @use HasFactory<\Database\Factories\DecisionFactory> */ /** @use HasFactory<\Database\Factories\DecisionFactory> */
use HasFactory; 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 public function actions(): BelongsToMany
{ {
@ -29,4 +36,9 @@ public function activities(): HasMany
{ {
return $this->hasMany(\App\Models\Activity::class); return $this->hasMany(\App\Models\Activity::class);
} }
public function emailTemplate(): BelongsTo
{
return $this->belongsTo(\App\Models\EmailTemplate::class, 'email_template_id');
}
} }

View File

@ -18,6 +18,7 @@ class Email extends Model
'is_primary', 'is_primary',
'is_active', 'is_active',
'valid', 'valid',
'receive_auto_mails',
'verified_at', 'verified_at',
'preferences', 'preferences',
'meta', 'meta',
@ -27,6 +28,7 @@ class Email extends Model
'is_primary' => 'boolean', 'is_primary' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
'valid' => 'boolean', 'valid' => 'boolean',
'receive_auto_mails' => 'boolean',
'verified_at' => 'datetime', 'verified_at' => 'datetime',
'preferences' => 'array', 'preferences' => 'array',
'meta' => 'array', 'meta' => 'array',

86
app/Models/EmailLog.php Normal file
View 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');
}
}

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

View 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];
}
}

View 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];
}
}

View File

@ -2,19 +2,21 @@
namespace App\Services; namespace App\Services;
use App\Models\Activity;
use App\Models\Client; use App\Models\Client;
use App\Models\ClientCase; use App\Models\ClientCase;
use App\Models\Contract; use App\Models\Contract;
use App\Models\Person\Person; use App\Models\Person\Person;
use Carbon\Carbon;
class EmailTemplateRenderer class EmailTemplateRenderer
{ {
/** /**
* Render subject and bodies using a simple {{ key }} replacement. * 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{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} * @return array{subject:string, html?:string, text?:string}
*/ */
public function render(array $template, array $ctx): array 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 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 = []; $out = [];
if (isset($ctx['client'])) { if (isset($ctx['client'])) {
$c = $ctx['client']; $c = $ctx['client'];
$out['client'] = [ $out['client'] = [
'id' => data_get($c, 'id'), 'id' => data_get($c, 'id'),
'uuid' => data_get($c, 'uuid'), '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'])) { if (isset($ctx['person'])) {
@ -68,6 +120,23 @@ protected function buildMap(array $ctx): array
'id' => data_get($c, 'id'), 'id' => data_get($c, 'id'),
'uuid' => data_get($c, 'uuid'), 'uuid' => data_get($c, 'uuid'),
'reference' => data_get($c, 'reference'), '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'])) { if (isset($ctx['contract'])) {
@ -76,13 +145,30 @@ protected function buildMap(array $ctx): array
'id' => data_get($co, 'id'), 'id' => data_get($co, 'id'),
'uuid' => data_get($co, 'uuid'), 'uuid' => data_get($co, 'uuid'),
'reference' => data_get($co, 'reference'), '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'); $meta = data_get($co, 'meta');
if (is_array($meta)) { if (is_array($meta)) {
$out['contract']['meta'] = $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'])) { if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
$out['extra'] = $ctx['extra']; $out['extra'] = $ctx['extra'];
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { computed, watch } from "vue";
import DialogModal from './DialogModal.vue'; import DialogModal from "./DialogModal.vue";
import InputLabel from './InputLabel.vue'; import InputLabel from "./InputLabel.vue";
import SectionTitle from './SectionTitle.vue'; import SectionTitle from "./SectionTitle.vue";
import TextInput from './TextInput.vue'; import TextInput from "./TextInput.vue";
import InputError from './InputError.vue'; import InputError from "./InputError.vue";
import PrimaryButton from './PrimaryButton.vue'; import PrimaryButton from "./PrimaryButton.vue";
import axios from 'axios'; import { useForm } from "@inertiajs/vue3";
/* /*
EmailCreateForm / Email editor EmailCreateForm / Email editor
@ -18,70 +18,77 @@ import axios from 'axios';
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
person: { type: Object, required: true }, 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 }, edit: { type: Boolean, default: false },
id: { type: Number, default: 0 }, 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); // Inertia useForm handles processing and errors for us
const errors = ref({});
const emit = defineEmits(['close']); const emit = defineEmits(["close"]);
const close = () => { const close = () => {
emit('close'); emit("close");
setTimeout(() => { errors.value = {}; }, 300); // 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({ const form = useForm({
value: '', value: "",
label: '' label: "",
receive_auto_mails: false,
}); });
const resetForm = () => { const resetForm = () => {
form.value = { value: '', label: '' }; form.reset("value", "label", "receive_auto_mails");
}; };
const create = async () => { const create = async () => {
processing.value = true; errors.value = {}; form.post(route("person.email.create", props.person), {
try { preserveScroll: true,
const { data } = await axios.post(route('person.email.create', props.person), form.value); onSuccess: () => {
if (!Array.isArray(props.person.emails)) props.person.emails = []; close();
props.person.emails.push(data.email); resetForm();
processing.value = false; close(); resetForm(); },
} catch (e) { });
errors.value = e?.response?.data?.errors || {}; processing.value = false;
}
}; };
const update = async () => { const update = async () => {
processing.value = true; errors.value = {}; form.put(route("person.email.update", { person: props.person, email_id: props.id }), {
try { preserveScroll: true,
const { data } = await axios.put(route('person.email.update', { person: props.person, email_id: props.id }), form.value); onSuccess: () => {
if (!Array.isArray(props.person.emails)) props.person.emails = []; close();
const idx = props.person.emails.findIndex(e => e.id === data.email.id); resetForm();
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;
}
}; };
watch( watch(
() => props.id, () => props.show,
(id) => { (newVal) => {
if (props.edit && id) { if (!newVal) {
const current = (props.person.emails || []).find(e => e.id === id); return;
if (current) {
form.value = {
value: current.value || current.email || current.address || '',
label: current.label || ''
};
return;
}
} }
resetForm(); if (props.edit && props.id) {
}, const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
{ immediate: true } 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()); 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"> <div class="col-span-6 sm:col-span-4">
<InputLabel for="em_value" value="E-pošta" /> <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" /> <TextInput
<InputError v-if="errors.value" v-for="err in errors.value" :key="err" :message="err" /> 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>
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<InputLabel for="em_label" value="Oznaka (neobvezno)" /> <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" /> <TextInput
<InputError v-if="errors.label" v-for="err in errors.label" :key="err" :message="err" /> 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>
<div class="flex justify-end mt-4"> <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> </div>
</form> </form>
</template> </template>

View File

@ -1,15 +1,27 @@
<script setup> <script setup>
// This component reuses EmailCreateForm's logic via props.edit=true // This component reuses EmailCreateForm's logic via props.edit=true
import EmailCreateForm from './EmailCreateForm.vue'; import EmailCreateForm from "./EmailCreateForm.vue";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
person: { type: Object, required: true }, person: { type: Object, required: true },
types: { type: Array, default: () => [] }, types: { type: Array, default: () => [] },
id: { type: Number, default: 0 }, 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> </script>
<template> <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> </template>

View File

@ -350,6 +350,7 @@ const getTRRs = (p) => {
@close="drawerAddEmail = false" @close="drawerAddEmail = false"
:person="person" :person="person"
:types="types.email_types ?? []" :types="types.email_types ?? []"
:is-client-context="!!person?.client"
/> />
<EmailUpdateForm <EmailUpdateForm
:show="drawerAddEmail && editEmail" :show="drawerAddEmail && editEmail"
@ -357,6 +358,7 @@ const getTRRs = (p) => {
:person="person" :person="person"
:types="types.email_types ?? []" :types="types.email_types ?? []"
:id="editEmailId" :id="editEmailId"
:is-client-context="!!person?.client"
/> />
<!-- TRR dialogs --> <!-- TRR dialogs -->

View File

@ -108,6 +108,13 @@ const navGroups = computed(() => [
"admin.email-templates.edit", "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", key: "admin.mail-profiles.index",
label: "Mail profili", label: "Mail profili",

View 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>

View 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>

View File

@ -4,12 +4,7 @@ import { Head, Link, useForm, router, usePage } from "@inertiajs/vue3";
import { ref, watch, computed, onMounted, nextTick } from "vue"; import { ref, watch, computed, onMounted, nextTick } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faArrowLeft, faEye } from "@fortawesome/free-solid-svg-icons"; import { faArrowLeft, faEye } from "@fortawesome/free-solid-svg-icons";
// Ensure Quill is available before importing the wrapper component // Keep Quill CSS for nicer preview styling, but remove the Quill editor itself
import Quill from "quill";
if (typeof window !== "undefined" && !window.Quill) {
// @ts-ignore
window.Quill = Quill;
}
import "quill/dist/quill.snow.css"; import "quill/dist/quill.snow.css";
const props = defineProps({ const props = defineProps({
@ -44,9 +39,8 @@ const previewHtml = computed(() => {
return containsDocScaffold(html) ? extractBody(html) : html; return containsDocScaffold(html) ? extractBody(html) : html;
} }
// Fallback to local content if server preview didn't provide HTML // Fallback to local content if server preview didn't provide HTML
return sourceMode.value // We only use the advanced editor now, so just show the <body> content when available
? extractBody(form.html_template || "") return extractBody(form.html_template || "");
: stripDocScaffold(form.html_template || "");
}); });
const docsRaw = ref(props.template?.documents ? [...props.template.documents] : []); const docsRaw = ref(props.template?.documents ? [...props.template.documents] : []);
const docs = computed(() => const docs = computed(() =>
@ -66,6 +60,12 @@ function updateLocalDoc(documentId, path, name = null, size = null) {
docsRaw.value.splice(idx, 1, next); 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) { function formatSize(bytes) {
if (!bytes && bytes !== 0) return ""; if (!bytes && bytes !== 0) return "";
const kb = bytes / 1024; const kb = bytes / 1024;
@ -97,6 +97,7 @@ const fetchPreview = () => {
// Always send full HTML so head/styles can be respected and inlined server-side // Always send full HTML so head/styles can be respected and inlined server-side
html: form.html_template, html: form.html_template,
text: form.text_template, text: form.text_template,
activity_id: sample.value.activity_id || undefined,
client_id: sample.value.client_id || undefined, client_id: sample.value.client_id || undefined,
case_id: sample.value.case_id || undefined, case_id: sample.value.case_id || undefined,
contract_id: sample.value.contract_id || undefined, contract_id: sample.value.contract_id || undefined,
@ -130,6 +131,7 @@ async function fetchFinalHtml() {
subject: form.subject_template, subject: form.subject_template,
html: form.html_template, html: form.html_template,
text: form.text_template, text: form.text_template,
activity_id: sample.value.activity_id || undefined,
client_id: sample.value.client_id || undefined, client_id: sample.value.client_id || undefined,
case_id: sample.value.case_id || undefined, case_id: sample.value.case_id || undefined,
contract_id: sample.value.contract_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 // Populate cascading selects immediately so the Client dropdown isn't empty
loadClients(); loadClients();
fetchPreview(); fetchPreview();
// Mount Quill editor directly // Advanced editor is the only mode
try { activeField.value = "html";
if (quillContainer.value) { initIframeEditor();
// 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();
}
}); });
// --- Variable insertion and sample entity selection --- // --- Variable insertion and sample entity selection ---
const subjectRef = ref(null); const subjectRef = ref(null);
const quillContainer = ref(null);
const htmlSourceRef = ref(null); const htmlSourceRef = ref(null);
const textRef = ref(null); const textRef = ref(null);
const activeField = ref(null); // 'subject' | 'html' | 'text' const activeField = ref("html"); // default to HTML for variable inserts
const quill = ref(null); // Raw HTML editor toggle (full-document source)
const sourceMode = ref(false); // toggle for HTML source editing const rawMode = ref(false);
// Advanced full-document editor that renders styles from <head> // Advanced full-document editor that renders styles from <head>
const advancedMode = ref(false); const advancedMode = ref(true);
const iframeRef = ref(null); const iframeRef = ref(null);
let iframeSyncing = false; let iframeSyncing = false;
const selectedImageSrc = ref(""); 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 // Detect and handle full-document HTML so Quill doesn't wipe content
function containsDocScaffold(html) { function containsDocScaffold(html) {
@ -261,20 +226,9 @@ function extractBody(html) {
return m ? m[1] : html; return m ? m[1] : html;
} }
// Retained for compatibility, but no longer used actively
function stripDocScaffold(html) { function stripDocScaffold(html) {
if (!html) return ""; return extractBody(html || "");
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();
} }
// Replace only the inner content of <body>...</body> in a full document // 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`); return htmlDoc.replace(/(<body[^>]*>)[\s\S]*?(<\/body>)/i, `$1${newBody || ""}$2`);
} }
// Keep Quill and textarea in sync when toggling source mode // Quill/source mode removed
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;
}
});
}
}
}
);
// Keep advanced editor in a stable state with source/quill // Advanced mode is always on
watch(
() => advancedMode.value,
async (on) => {
if (on) {
sourceMode.value = false;
await nextTick();
initIframeEditor();
}
}
);
// When HTML changes externally, reflect it into iframe (unless we're the ones syncing) // When HTML changes externally, reflect it into iframe (unless we're the ones syncing)
watch( watch(
() => form.html_template, () => form.html_template,
async () => { async () => {
if (!advancedMode.value || iframeSyncing) return; if (iframeSyncing) return;
await nextTick(); await nextTick();
writeIframeDocument(); writeIframeDocument();
} }
); );
// Re-initialize iframe editor when switching back from Raw HTML
watch(
() => rawMode.value,
async (on) => {
if (!on) {
await nextTick();
initIframeEditor();
}
}
);
function ensureFullDoc(html) { function ensureFullDoc(html) {
if (!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>'; 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 { } else {
selectedImageSrc.value = ""; selectedImageSrc.value = "";
} }
// Ensure variable buttons target HTML editor
activeField.value = "html";
}); });
const handler = debounce(() => { const handler = debounce(() => {
if (!advancedMode.value) return; if (!advancedMode.value) return;
@ -416,6 +321,17 @@ function initIframeEditor() {
doc.addEventListener("keyup", handler); 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) { function iframeExec(command, value = null) {
const iframe = iframeRef.value; const iframe = iframeRef.value;
if (!iframe) return; if (!iframe) return;
@ -522,6 +438,128 @@ function setActive(field) {
activeField.value = 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) { function insertAtCursor(el, value, modelGetter, modelSetter) {
if (!el) return; if (!el) return;
const start = el.selectionStart ?? 0; const start = el.selectionStart ?? 0;
@ -549,8 +587,8 @@ function insertPlaceholder(token) {
(v) => (form.subject_template = v) (v) => (form.subject_template = v)
); );
} else if (activeField.value === "html") { } else if (activeField.value === "html") {
// If in source mode, treat HTML as textarea // If editing raw source, insert at caret into textarea
if (sourceMode.value && htmlSourceRef.value) { if (rawMode.value && htmlSourceRef.value) {
insertAtCursor( insertAtCursor(
htmlSourceRef.value, htmlSourceRef.value,
content, content,
@ -559,16 +597,31 @@ function insertPlaceholder(token) {
); );
return; return;
} }
// Insert into Quill editor at current selection // Otherwise, insert into the iframe at caret position
if (quill.value) { // Insert into the iframe at caret position without rewriting the document
let range = quill.value.getSelection(true); const iframe = iframeRef.value;
const index = range ? range.index : quill.value.getLength() - 1; const doc = iframe?.contentDocument;
quill.value.insertText(index, content, "user"); if (doc) {
quill.value.setSelection(index + content.length, 0, "user"); const sel = doc.getSelection();
// Sync back to form model as HTML if (sel && sel.rangeCount > 0) {
form.html_template = quill.value.root.innerHTML; 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 { } else {
// Fallback: append to the end of the model // last resort
form.html_template = (form.html_template || "") + content; form.html_template = (form.html_template || "") + content;
} }
} else if (activeField.value === "text" && textRef.value) { } else if (activeField.value === "text" && textRef.value) {
@ -581,53 +634,7 @@ function insertPlaceholder(token) {
} }
} }
// Quill toolbar & image upload handler // Quill handlers removed; image actions handled by iframe toolbar
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();
}
const placeholderGroups = computed(() => { const placeholderGroups = computed(() => {
const groups = []; const groups = [];
@ -643,10 +650,15 @@ const placeholderGroups = computed(() => {
]); ]);
} }
if (want.has("client")) { 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")) { 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")) { if (want.has("contract")) {
add("contract", "Contract", [ add("contract", "Contract", [
@ -657,13 +669,28 @@ const placeholderGroups = computed(() => {
"contract.meta.some_key", "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 // Extra is always useful for ad-hoc data
add("extra", "Extra", ["extra.some_key"]); add("extra", "Extra", ["extra.some_key"]);
return groups; return groups;
}); });
// Sample entity selection for preview // 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 // Cascading select options
const clients = ref([]); const clients = ref([]);
@ -716,6 +743,10 @@ watch(
() => sample.value.contract_id, () => sample.value.contract_id,
() => doPreview() () => doPreview()
); );
watch(
() => sample.value.activity_id,
() => doPreview()
);
function applySample() { function applySample() {
fetchPreview(); fetchPreview();
@ -745,6 +776,7 @@ function sendTest() {
subject: form.subject_template, subject: form.subject_template,
html: form.html_template, html: form.html_template,
text: form.text_template, text: form.text_template,
activity_id: sample.value.activity_id || undefined,
client_id: sample.value.client_id || undefined, client_id: sample.value.client_id || undefined,
person_id: sample.value.person_id || undefined, person_id: sample.value.person_id || undefined,
case_id: sample.value.case_id || undefined, case_id: sample.value.case_id || undefined,
@ -780,6 +812,8 @@ function sendTest() {
} }
} catch {} } catch {}
notify("Testni e-poštni naslov je bil poslan.", "success"); 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: () => { onError: () => {
// Validation errors trigger onError; controller may also set flash('error') on redirect // 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> </script>
<template> <template>
@ -887,35 +930,22 @@ function sendTest() {
<label class="label">HTML vsebina</label> <label class="label">HTML vsebina</label>
<div class="flex items-center gap-4 text-xs text-gray-600"> <div class="flex items-center gap-4 text-xs text-gray-600">
<label class="inline-flex items-center gap-2"> <label class="inline-flex items-center gap-2">
<input type="checkbox" v-model="advancedMode" /> Napredni (poln <input type="checkbox" v-model="rawMode" /> Raw HTML
dokument)
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" v-model="sourceMode" :disabled="advancedMode" />
Source HTML
</label> </label>
</div> </div>
</div> </div>
<!-- Quill body editor (hidden when advanced editor is active) --> <!-- Raw HTML textarea -->
<div v-show="!sourceMode && !advancedMode"> <div v-show="rawMode">
<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">
<textarea <textarea
v-model="form.html_template" v-model="form.html_template"
rows="12" rows="16"
class="input font-mono" class="input font-mono"
ref="htmlSourceRef" ref="htmlSourceRef"
@focus="setActive('html')" @focus="setActive('html')"
></textarea> ></textarea>
</div> </div>
<!-- Advanced full-document editor (iframe) --> <!-- 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"> <div class="flex flex-wrap gap-2">
<button <button
type="button" type="button"
@ -1057,6 +1087,15 @@ function sendTest() {
<div> <div>
<div class="label">Sample entities</div> <div class="label">Sample entities</div>
<div class="grid grid-cols-2 gap-2"> <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"> <label class="text-xs text-gray-600">
Client Client
<select v-model="sample.client_id" class="input h-9"> <select v-model="sample.client_id" class="input h-9">
@ -1121,10 +1160,10 @@ function sendTest() {
</div> </div>
<div> <div>
<div class="label">HTML</div> <div class="label">HTML</div>
<div <iframe
class="p-3 rounded-lg bg-gray-50 border ql-editor max-h-[480px] overflow-auto" ref="previewIframeRef"
v-html="previewHtml" class="w-full h-[480px] border rounded bg-white"
></div> ></iframe>
</div> </div>
<div> <div>
<div class="label">Text</div> <div class="label">Text</div>
@ -1134,7 +1173,8 @@ function sendTest() {
</div> </div>
<div class="text-xs text-gray-500" v-pre> <div class="text-xs text-gray-500" v-pre>
Available placeholders example: {{ person.full_name }}, {{ client.uuid }}, 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>
<div class="mt-4 flex flex-col sm:flex-row sm:items-end gap-2"> <div class="mt-4 flex flex-col sm:flex-row sm:items-end gap-2">
<div class="w-full sm:w-auto"> <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" class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
>Odpri</a >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> </div>
</div> </div>

View File

@ -8,7 +8,7 @@ import TextInput from "@/Components/TextInput.vue";
import CurrencyInput from "@/Components/CurrencyInput.vue"; import CurrencyInput from "@/Components/CurrencyInput.vue";
import { useForm } from "@inertiajs/vue3"; import { useForm } from "@inertiajs/vue3";
import { FwbTextarea } from "flowbite-vue"; import { FwbTextarea } from "flowbite-vue";
import { ref, watch } from "vue"; import { ref, watch, computed } from "vue";
const props = defineProps({ const props = defineProps({
show: { show: {
@ -38,6 +38,7 @@ const form = useForm({
action_id: props.actions[0].id, action_id: props.actions[0].id,
decision_id: props.actions[0].decisions[0].id, decision_id: props.actions[0].decisions[0].id,
contract_uuid: props.contractUuid, contract_uuid: props.contractUuid,
send_auto_mail: true,
}); });
watch( watch(
@ -45,6 +46,8 @@ watch(
(action_id) => { (action_id) => {
decisions.value = props.actions.filter((el) => el.id === action_id)[0].decisions; decisions.value = props.actions.filter((el) => el.id === action_id)[0].decisions;
form.decision_id = decisions.value[0].id; 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> </script>
<template> <template>
<DialogModal :show="show" @close="close"> <DialogModal :show="show" @close="close">
@ -179,6 +217,22 @@ watch(
placeholder="0,00" placeholder="0,00"
/> />
</div> </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"> <div class="flex justify-end mt-4">
<ActionMessage :on="form.recentlySuccessful" class="me-3"> <ActionMessage :on="form.recentlySuccessful" class="me-3">
Shranjuje. Shranjuje.

View File

@ -257,6 +257,7 @@ const submitAttachSegment = () => {
:types="types" :types="types"
tab-color="red-600" tab-color="red-600"
:person="client_case.person" :person="client_case.person"
/> />
</div> </div>
</div> </div>

View File

@ -13,7 +13,8 @@ import ActionMessage from '@/Components/ActionMessage.vue';
const props = defineProps({ const props = defineProps({
decisions: Array, decisions: Array,
actions: Array actions: Array,
emailTemplates: { type: Array, default: () => [] }
}); });
const drawerEdit = ref(false); const drawerEdit = ref(false);
@ -22,6 +23,8 @@ const showDelete = ref(false);
const toDelete = ref(null); const toDelete = ref(null);
const search = ref(''); const search = ref('');
const selectedTemplateId = ref(null);
const onlyAutoMail = ref(false);
const actionOptions = ref([]); const actionOptions = ref([]);
@ -29,13 +32,17 @@ const form = useForm({
id: 0, id: 0,
name: '', name: '',
color_tag: '', color_tag: '',
actions: [] actions: [],
auto_mail: false,
email_template_id: null,
}); });
const createForm = useForm({ const createForm = useForm({
name: '', name: '',
color_tag: '', color_tag: '',
actions: [] actions: [],
auto_mail: false,
email_template_id: null,
}); });
const openEditDrawer = (item) => { const openEditDrawer = (item) => {
@ -43,6 +50,8 @@ const openEditDrawer = (item) => {
form.id = item.id; form.id = item.id;
form.name = item.name; form.name = item.name;
form.color_tag = item.color_tag; form.color_tag = item.color_tag;
form.auto_mail = !!item.auto_mail;
form.email_template_id = item.email_template_id || null;
drawerEdit.value = true; drawerEdit.value = true;
item.actions.forEach((a) => { item.actions.forEach((a) => {
@ -79,8 +88,12 @@ onMounted(() => {
const filtered = computed(() => { const filtered = computed(() => {
const term = search.value?.toLowerCase() ?? ''; const term = search.value?.toLowerCase() ?? '';
const tplId = selectedTemplateId.value ? Number(selectedTemplateId.value) : null;
return (props.decisions || []).filter(d => { 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> </script>
<template> <template>
<div class="p-4 flex items-center justify-between gap-3"> <div class="p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<TextInput v-model="search" placeholder="Search decisions..." class="w-full sm:w-72" /> <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> <PrimaryButton @click="openCreateDrawer">+ Dodaj odločitev</PrimaryButton>
</div> </div>
@ -131,6 +154,7 @@ const destroyDecision = () => {
<fwb-table-head-cell>Name</fwb-table-head-cell> <fwb-table-head-cell>Name</fwb-table-head-cell>
<fwb-table-head-cell>Color tag</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>Belongs to actions</fwb-table-head-cell>
<fwb-table-head-cell>Auto mail</fwb-table-head-cell>
<fwb-table-head-cell> <fwb-table-head-cell>
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</fwb-table-head-cell> </fwb-table-head-cell>
@ -146,6 +170,14 @@ const destroyDecision = () => {
</div> </div>
</fwb-table-cell> </fwb-table-cell>
<fwb-table-cell>{{ d.actions.length }}</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> <fwb-table-cell>
<button class="px-2" @click="openEditDrawer(d)"><EditIcon size="md" css="text-gray-500" /></button> <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> <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" autocomplete="name"
/> />
</div> </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"> <div class="col-span-6 sm:col-span-4">
<InputLabel for="colorTag" value="Barva"/> <InputLabel for="colorTag" value="Barva"/>
<div class="mt-1 w-full border flex p-1"> <div class="mt-1 w-full border flex p-1">
@ -228,6 +276,22 @@ const destroyDecision = () => {
autocomplete="name" autocomplete="name"
/> />
</div> </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"> <div class="col-span-6 sm:col-span-4">
<InputLabel for="colorTagCreate" value="Barva"/> <InputLabel for="colorTagCreate" value="Barva"/>
<div class="mt-1 w-full border flex p-1"> <div class="mt-1 w-full border flex p-1">

View File

@ -8,7 +8,8 @@ import DecisionTable from '../Partials/DecisionTable.vue';
const props = defineProps({ const props = defineProps({
actions: Array, actions: Array,
decisions: Array, decisions: Array,
segments: Array segments: Array,
email_templates: { type: Array, default: () => [] }
}); });
const activeTab = ref('actions') const activeTab = ref('actions')
@ -25,7 +26,7 @@ const activeTab = ref('actions')
<ActionTable :actions="actions" :decisions="decisions" :segments="segments" /> <ActionTable :actions="actions" :decisions="decisions" :segments="segments" />
</fwb-tab> </fwb-tab>
<fwb-tab name="decisions" title="Decisions"> <fwb-tab name="decisions" title="Decisions">
<DecisionTable :decisions="decisions" :actions="actions" /> <DecisionTable :decisions="decisions" :actions="actions" :email-templates="email_templates" />
</fwb-tab> </fwb-tab>
</fwb-tabs> </fwb-tabs>
</div> </div>

View File

@ -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/{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/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::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 // 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', [\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/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'); 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 // Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service

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

View File

@ -2,12 +2,40 @@
use App\Services\EmailTemplateRenderer; 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 () { it('renders placeholders in subject, html and text', function () {
$renderer = new EmailTemplateRenderer; $renderer = new EmailTemplateRenderer;
$template = [ $template = [
'subject' => 'Hello {{ person.full_name }} - {{ contract.reference }}', '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 }}', 'text' => 'Client: {{ client.uuid }} Extra: {{ extra.note }}',
]; ];
@ -15,7 +43,7 @@
'person' => (object) ['first_name' => 'Jane', 'last_name' => 'Doe', 'email' => 'jane@example.test'], 'person' => (object) ['first_name' => 'Jane', 'last_name' => 'Doe', 'email' => 'jane@example.test'],
'client' => (object) ['uuid' => 'cl-123'], 'client' => (object) ['uuid' => 'cl-123'],
'client_case' => (object) ['uuid' => 'cc-456', 'reference' => 'REF-1'], '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'], 'extra' => ['note' => 'hello'],
]; ];
@ -24,5 +52,6 @@
expect($result['subject'])->toBe('Hello Jane Doe - CON-42'); expect($result['subject'])->toBe('Hello Jane Doe - CON-42');
expect($result['html'])->toContain('Case: cc-456'); expect($result['html'])->toContain('Case: cc-456');
expect($result['html'])->toContain('Meta: bar'); expect($result['html'])->toContain('Meta: bar');
expect($result['html'])->toContain('Amount: 1.000,00 €');
expect($result['text'])->toBe('Client: cl-123 Extra: hello'); expect($result['text'])->toBe('Client: cl-123 Extra: hello');
}); });