37 Commits

Author SHA1 Message Date
Simon Pocrnjič ced80ebea6 Fixed Drawer width on phone 2026-06-21 21:03:53 +02:00
Simon Pocrnjič 2e5532aaa8 Object changed flex from row to col 2026-06-21 20:04:10 +02:00
Simon Pocrnjič f8f019408a Phone view case updated 2026-06-21 19:49:04 +02:00
Simon Pocrnjič ea9376c713 Phone view update 2026-06-20 23:42:43 +02:00
Simon Pocrnjič 8ffc60aba5 Removed sender bbc 2026-05-21 10:10:34 +02:00
Simon Pocrnjič 7ab890005b fixed the fixed 2026-05-18 12:37:12 +02:00
Simon Pocrnjič b6405764a9 fixed meta param for email template 2026-05-18 12:18:20 +02:00
Simon Pocrnjič 256b311c43 Activity client case archived contract not auto-selected 2026-05-17 23:55:17 +02:00
Simon Pocrnjič 32fe2fbc9b Added "auto_mailer" to mail profile so user can select which profiles are appropriate for auto mails to cliens through activities
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 22:12:57 +02:00
Simon Pocrnjič e3bc5da7e3 Package and individual mail sender, new report, and other changes
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 21:32:30 +02:00
sipo b6bfa17980 Adding activity dialog now auto selects all available contracts use has option to remove them, adding documents on archived contracts is not limited anymore. 2026-05-10 16:30:45 +02:00
Simon Pocrnjič fd81e8ce2d Users custome login redirect 2026-04-21 20:24:58 +02:00
Simon Pocrnjič 054202dc32 Major change update laravel, inertia v2 -> v3, other changes 2026-04-19 13:47:30 +02:00
Simon Pocrnjič 92f54f7103 Changes to phone view, fixed infinity scroll issues with page refresh, updated design a bit 2026-04-18 12:28:15 +02:00
Simon Pocrnjič 8f8c5c5a12 Updated mobile view for field jobs 2026-04-16 23:11:49 +02:00
Simon Pocrnjič 187cb4f127 Fixed when contract is archived all active field jobs for contract are cancaled 2026-04-16 21:52:17 +02:00
Simon Pocrnjič 7881508a7b Fixed dates 2026-04-14 17:41:05 +02:00
Simon Pocrnjič 821985469e Translation user seen text to Slovenian 2026-04-12 21:56:13 +02:00
Simon Pocrnjič a5257df2b7 Added condition options for events to trigger decisions 2026-04-12 21:35:16 +02:00
Simon Pocrnjič 342d9d0700 activity is now added when contract balance is changed 2026-04-02 21:44:15 +02:00
Simon Pocrnjič d54fc9914d reverted copying image change 2026-03-18 21:54:14 +01:00
Simon Pocrnjič f8d1579cb2 Testing image copying 2026-03-18 21:48:39 +01:00
Simon Pocrnjič d80c99c6c0 Fixed dropdown menu where if window small and parts of menu hidden no scroll appeared and user was stuck, also fixed time js which showed +1h, enabled image copying documents 2026-03-18 21:09:30 +01:00
Simon Pocrnjič 9c773be3ec fixed bug in permission edit where checked groups were not checked and remove left and right click image zooming 2026-03-17 21:04:25 +01:00
Simon Pocrnjič 9c6878d1bd Option to add installment to contract/account to increace balance amount same as payment and can be deleted which will reduce balance amount by new amount of the installment deleted, call later added badge to show active call laters 2026-03-11 21:04:20 +01:00
Simon Pocrnjič 5f9d00b575 display call_back_at in activity table 2026-03-09 19:31:03 +01:00
Simon Pocrnjič 2cc765912e Fixed is overdue 2026-03-09 19:19:43 +01:00
Simon Pocrnjič b6e66f0e64 timeZone changed to UTC 2026-03-09 19:14:52 +01:00
Simon Pocrnjič 0aa95fba47 Fixed clock call later 2026-03-09 18:49:08 +01:00
Simon Pocrnjič 0b082549b9 fixed something 2026-03-09 06:30:49 +01:00
Simon Pocrnjič b0d2aa93ab Added call later, option to limit auto mail so for a client person email you can limit which decision activity will be send to that specific email and moved SMS packages from admin panel to default app view 2026-03-08 21:42:39 +01:00
Simon Pocrnjič c16dd51199 Can add activities for archived 2026-02-23 20:08:32 +01:00
Simon Pocrnjič 245caea4dc Remove laravel pagination from contract table / ClientCase view and used replacing it with client pagination 2026-02-14 21:05:01 +01:00
Simon Pocrnjič dda118a005 Fixed some problems 2026-02-05 21:17:16 +01:00
Simon Pocrnjič 8147fedd04 workflow fixed multiselect, combobox width was not limited when selecting desicisions 2026-02-01 19:35:38 +01:00
Simon Pocrnjič b1c531bb70 updated sms package creator, removed result for segments with exeption true, replaced some ui elements 2026-02-01 13:43:18 +01:00
Simon Pocrnjič 9cc1b7072c added download button for orignal import csv file 2026-02-01 09:22:34 +01:00
148 changed files with 11747 additions and 2590 deletions
@@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreInstallmentRequest;
use App\Models\Account;
use App\Models\Activity;
use App\Models\Booking;
use App\Models\Installment;
use App\Models\InstallmentSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
class AccountInstallmentController extends Controller
{
public function list(Account $account): JsonResponse
{
$installments = Installment::query()
->where('account_id', $account->id)
->orderByDesc('installment_at')
->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'installment_at', 'created_at'])
->map(function (Installment $i) {
return [
'id' => $i->id,
'amount' => (float) $i->amount,
'balance_before' => (float) ($i->balance_before ?? 0),
'currency' => $i->currency,
'reference' => $i->reference,
'installment_at' => optional($i->installment_at)?->toDateString(),
'created_at' => optional($i->created_at)?->toDateTimeString(),
];
});
return response()->json([
'account' => [
'id' => $account->id,
'balance_amount' => $account->balance_amount,
],
'installments' => $installments,
]);
}
public function store(StoreInstallmentRequest $request, Account $account): RedirectResponse
{
$validated = $request->validated();
$amountCents = (int) round(((float) $validated['amount']) * 100);
$settings = InstallmentSetting::query()->first();
$defaultCurrency = strtoupper($settings->default_currency ?? 'EUR');
$installment = Installment::query()->create([
'account_id' => $account->id,
'balance_before' => (float) ($account->balance_amount ?? 0),
'amount' => (float) $validated['amount'],
'currency' => strtoupper($validated['currency'] ?? $defaultCurrency),
'reference' => $validated['reference'] ?? null,
'installment_at' => $validated['installment_at'] ?? now(),
'meta' => $validated['meta'] ?? null,
'created_by' => $request->user()?->id,
]);
// Debit booking — increases the account balance
Booking::query()->create([
'account_id' => $account->id,
'payment_id' => null,
'amount_cents' => $amountCents,
'type' => 'debit',
'description' => $installment->reference ? ('Obremenitev '.$installment->reference) : 'Obremenitev',
'booked_at' => $installment->installment_at ?? now(),
]);
if ($settings && ($settings->create_activity_on_installment ?? false)) {
$note = $settings->activity_note_template ?? 'Dodan obrok';
$note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $installment->currency], $note);
$account->refresh();
$beforeStr = number_format((float) ($installment->balance_before ?? 0), 2, ',', '.').' '.$installment->currency;
$afterStr = number_format((float) ($account->balance_amount ?? 0), 2, ',', '.').' '.$installment->currency;
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: obrok)";
$account->loadMissing('contract');
$clientCaseId = $account->contract?->client_case_id;
if ($clientCaseId) {
$activity = Activity::query()->create([
'due_date' => null,
'amount' => $amountCents / 100,
'note' => $note,
'action_id' => $settings->default_action_id,
'decision_id' => $settings->default_decision_id,
'client_case_id' => $clientCaseId,
'contract_id' => $account->contract_id,
]);
$installment->update(['activity_id' => $activity->id]);
}
}
return back()->with('success', 'Installment created.');
}
public function destroy(Account $account, Installment $installment): RedirectResponse|JsonResponse
{
if ($installment->account_id !== $account->id) {
abort(404);
}
// Delete related debit booking(s) to revert balance via model events
Booking::query()
->where('account_id', $account->id)
->where('type', 'debit')
->whereDate('booked_at', optional($installment->installment_at)?->toDateString())
->where('amount_cents', (int) round(((float) $installment->amount) * 100))
->whereNull('payment_id')
->get()
->each->delete();
if ($installment->activity_id) {
$activity = Activity::query()->find($installment->activity_id);
if ($activity) {
$activity->delete();
}
}
$installment->delete();
if (request()->wantsJson()) {
return response()->json(['success' => true]);
}
return back()->with('success', 'Installment deleted.');
}
}
@@ -6,6 +6,7 @@
use App\Models\EmailLog; use App\Models\EmailLog;
use App\Models\EmailTemplate; use App\Models\EmailTemplate;
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 Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -69,4 +70,15 @@ public function show(EmailLog $emailLog): Response
'log' => $emailLog, 'log' => $emailLog,
]); ]);
} }
public function body(EmailLog $emailLog): JsonResponse
{
$this->authorize('viewAny', EmailTemplate::class);
$emailLog->load('body');
return response()->json([
'html' => $emailLog->body?->body_html ?? '',
]);
}
} }
@@ -13,6 +13,7 @@
use App\Models\EmailLog; use App\Models\EmailLog;
use App\Models\EmailLogStatus; use App\Models\EmailLogStatus;
use App\Models\EmailTemplate; use App\Models\EmailTemplate;
use App\Models\MailProfile;
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\JsonResponse;
@@ -55,8 +56,14 @@ public function create(): Response
{ {
$this->authorize('create', EmailTemplate::class); $this->authorize('create', EmailTemplate::class);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/EmailTemplates/Edit', [ return Inertia::render('Admin/EmailTemplates/Edit', [
'template' => null, 'template' => null,
'actions' => $actions,
]); ]);
} }
@@ -93,7 +100,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
// Context resolution (shared logic with renderFinalHtml) // Context resolution (shared logic with renderFinalHtml)
$ctx = []; $ctx = [];
if ($id = $request->integer('activity_id')) { if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id); $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
if ($activity) { if ($activity) {
$ctx['activity'] = $activity; $ctx['activity'] = $activity;
// Derive base entities from activity when not explicitly provided // Derive base entities from activity when not explicitly provided
@@ -110,7 +117,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
} }
} }
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', 'account'])->find($id);
if ($contract) { if ($contract) {
$ctx['contract'] = $contract; $ctx['contract'] = $contract;
if ($contract->clientCase) { if ($contract->clientCase) {
@@ -140,6 +147,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
} }
} }
$ctx['extra'] = (array) $request->input('extra', []); $ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
$rendered = $renderer->render([ $rendered = $renderer->render([
'subject' => $subject, 'subject' => $subject,
@@ -161,8 +169,14 @@ public function edit(EmailTemplate $emailTemplate): Response
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']); $q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
}]); }]);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/EmailTemplates/Edit', [ return Inertia::render('Admin/EmailTemplates/Edit', [
'template' => $emailTemplate, 'template' => $emailTemplate,
'actions' => $actions,
]); ]);
} }
@@ -181,7 +195,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
// Context resolution // Context resolution
$ctx = []; $ctx = [];
if ($id = $request->integer('activity_id')) { if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id); $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
if ($activity) { if ($activity) {
$ctx['activity'] = $activity; $ctx['activity'] = $activity;
if ($activity->contract && ! isset($ctx['contract'])) { if ($activity->contract && ! isset($ctx['contract'])) {
@@ -197,7 +211,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
} }
} }
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', 'account'])->find($id);
if ($contract) { if ($contract) {
$ctx['contract'] = $contract; $ctx['contract'] = $contract;
if ($contract->clientCase) { if ($contract->clientCase) {
@@ -227,6 +241,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
} }
} }
$ctx['extra'] = (array) $request->input('extra', []); $ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
// Render preview values; we store a minimal snapshot on the log // Render preview values; we store a minimal snapshot on the log
$rendered = $renderer->render([ $rendered = $renderer->render([
@@ -293,7 +308,7 @@ 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')) { if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id); $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
if ($activity) { if ($activity) {
$ctx['activity'] = $activity; $ctx['activity'] = $activity;
if ($activity->contract && ! isset($ctx['contract'])) { if ($activity->contract && ! isset($ctx['contract'])) {
@@ -309,7 +324,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
} }
} }
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', 'account'])->find($id);
if ($contract) { if ($contract) {
$ctx['contract'] = $contract; $ctx['contract'] = $contract;
if ($contract->clientCase) { if ($contract->clientCase) {
@@ -339,6 +354,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
} }
} }
$ctx['extra'] = (array) $request->input('extra', []); $ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
$rendered = $renderer->render([ $rendered = $renderer->render([
'subject' => $subject, 'subject' => $subject,
@@ -26,7 +26,7 @@ public function index(): Response
->orderBy('priority') ->orderBy('priority')
->orderBy('id') ->orderBy('id')
->get([ ->get([
'id', 'name', 'active', 'host', 'port', 'encryption', 'from_address', 'priority', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at', 'id', 'name', 'active', 'auto_mailer', 'host', 'port', 'username', 'from_name', 'encryption', 'from_address', 'priority', 'signature', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
]); ]);
return Inertia::render('Admin/MailProfiles/Index', [ return Inertia::render('Admin/MailProfiles/Index', [
@@ -76,6 +76,15 @@ public function toggle(Request $request, MailProfile $mailProfile)
return back()->with('success', 'Status updated'); return back()->with('success', 'Status updated');
} }
public function toggleAutoMailer(Request $request, MailProfile $mailProfile)
{
$this->authorize('update', $mailProfile);
$mailProfile->auto_mailer = ! $mailProfile->auto_mailer;
$mailProfile->save();
return back()->with('success', 'Auto-mailer updated');
}
public function test(Request $request, MailProfile $mailProfile) public function test(Request $request, MailProfile $mailProfile)
{ {
$this->authorize('test', $mailProfile); $this->authorize('test', $mailProfile);
+314 -12
View File
@@ -3,13 +3,16 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEmailPackageFromContractsRequest;
use App\Http\Requests\StorePackageFromContractsRequest; use App\Http\Requests\StorePackageFromContractsRequest;
use App\Http\Requests\StorePackageRequest; use App\Http\Requests\StorePackageRequest;
use App\Jobs\PackageItemEmailJob;
use App\Jobs\PackageItemSmsJob; use App\Jobs\PackageItemSmsJob;
use App\Models\Contract; use App\Models\Contract;
use App\Models\Package; use App\Models\Package;
use App\Models\PackageItem; use App\Models\PackageItem;
use App\Models\SmsTemplate; use App\Models\SmsTemplate;
use App\Services\Contact\EmailSelector;
use App\Services\Contact\PhoneSelector; use App\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService; use App\Services\Sms\SmsService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -21,20 +24,40 @@
class PackageController extends Controller class PackageController extends Controller
{ {
public function index(Request $request): Response public function landing(): Response
{
return Inertia::render('Packages/Index');
}
public function smsIndex(Request $request): Response
{ {
$perPage = $request->input('per_page') ?? 25; $perPage = $request->input('per_page') ?? 25;
$packages = Package::query() $packages = Package::query()
->where('type', Package::TYPE_SMS)
->latest('id') ->latest('id')
->paginate($perPage); ->paginate($perPage);
return Inertia::render('Admin/Packages/Index', [ return Inertia::render('Packages/Sms/Index', [
'packages' => $packages, 'packages' => $packages,
]); ]);
} }
public function create(Request $request): Response public function emailIndex(Request $request): Response
{
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query()
->where('type', Package::TYPE_EMAIL)
->latest('id')
->paginate($perPage);
return Inertia::render('Packages/Mail/Index', [
'packages' => $packages,
]);
}
public function smsCreate(Request $request): Response
{ {
// Minimal lookups for create form (active only) // Minimal lookups for create form (active only)
$profiles = \App\Models\SmsProfile::query() $profiles = \App\Models\SmsProfile::query()
@@ -50,6 +73,7 @@ public function create(Request $request): Response
->get(['id', 'name', 'content']); ->get(['id', 'name', 'content']);
$segments = \App\Models\Segment::query() $segments = \App\Models\Segment::query()
->where('active', true) ->where('active', true)
->where('exclude', false)
->orderBy('name') ->orderBy('name')
->get(['id', 'name']); ->get(['id', 'name']);
// Provide a lightweight list of recent clients with person names for filtering // Provide a lightweight list of recent clients with person names for filtering
@@ -68,7 +92,7 @@ public function create(Request $request): Response
}) })
->values(); ->values();
return Inertia::render('Admin/Packages/Create', [ return Inertia::render('Packages/Sms/Create', [
'profiles' => $profiles, 'profiles' => $profiles,
'senders' => $senders, 'senders' => $senders,
'templates' => $templates, 'templates' => $templates,
@@ -77,7 +101,53 @@ public function create(Request $request): Response
]); ]);
} }
public function show(Package $package, SmsService $sms): Response public function emailCreate(): Response
{
$emailTemplates = \App\Models\EmailTemplate::query()
->where('active', true)
->where('client', false)
->orderBy('name')
->get(['id', 'name', 'subject_template', 'text_template', 'html_template'])
->map(fn ($t) => [
'id' => $t->id,
'name' => $t->name,
'subject_template' => $t->subject_template,
'text_template' => $t->text_template,
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $t->html_template),
])->values();
$mailProfiles = \App\Models\MailProfile::query()
->where('active', true)
->orderBy('priority')
->get(['id', 'name']);
$segments = \App\Models\Segment::query()
->where('active', true)
->where('exclude', false)
->orderBy('name')
->get(['id', 'name']);
$clients = \App\Models\Client::query()
->with(['person' => function ($q) {
$q->select('id', 'uuid', 'full_name');
}])
->latest('id')
->get(['id', 'uuid', 'person_id'])
->map(function ($c) {
return [
'id' => $c->id,
'uuid' => $c->uuid,
'name' => $c->person?->full_name ?? ('Client #'.$c->id),
];
})
->values();
return Inertia::render('Packages/Mail/Create', [
'emailTemplates' => $emailTemplates,
'mailProfiles' => $mailProfiles,
'segments' => $segments,
'clients' => $clients,
]);
}
public function smsShow(Package $package, SmsService $sms): Response
{ {
$items = $package->items()->latest('id')->paginate(25); $items = $package->items()->latest('id')->paginate(25);
@@ -211,13 +281,23 @@ public function show(Package $package, SmsService $sms): Response
} }
} }
return Inertia::render('Admin/Packages/Show', [ return Inertia::render('Packages/Sms/Show', [
'package' => $package, 'package' => $package,
'items' => $items, 'items' => $items,
'preview' => $preview, 'preview' => $preview,
]); ]);
} }
public function emailShow(Package $package): Response
{
$items = $package->items()->latest('id')->paginate(25);
return Inertia::render('Packages/Mail/Show', [
'package' => $package,
'items' => $items,
]);
}
public function store(StorePackageRequest $request): RedirectResponse public function store(StorePackageRequest $request): RedirectResponse
{ {
$data = $request->validated(); $data = $request->validated();
@@ -259,7 +339,11 @@ public function dispatch(Package $package): RedirectResponse
return back()->with('error', 'Package not in a dispatchable state.'); return back()->with('error', 'Package not in a dispatchable state.');
} }
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) { $jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) use ($package) {
if ($package->type === Package::TYPE_EMAIL) {
return new PackageItemEmailJob($item->id);
}
return new PackageItemSmsJob($item->id); return new PackageItemSmsJob($item->id);
})->all(); })->all();
@@ -285,7 +369,7 @@ public function dispatch(Package $package): RedirectResponse
$package->save(); $package->save();
} }
}) })
->onQueue('sms') ->onQueue($package->type === Package::TYPE_EMAIL ? 'email' : 'sms')
->dispatch(); ->dispatch();
return back()->with('success', 'Package dispatched'); return back()->with('success', 'Package dispatched');
@@ -321,7 +405,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
$request->validate([ $request->validate([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'], 'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'], 'q' => ['nullable', 'string'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'], 'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_mobile' => ['nullable', 'boolean'], 'only_mobile' => ['nullable', 'boolean'],
'only_validated' => ['nullable', 'boolean'], 'only_validated' => ['nullable', 'boolean'],
@@ -333,12 +416,12 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null; $segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
$query = Contract::query() $query = Contract::query()
->with([ ->with([
'clientCase.person.phones', 'clientCase.person.phones',
'clientCase.client.person', 'clientCase.client.person',
'account', 'account',
'segments:id,name',
]) ])
->select('contracts.*') ->select('contracts.*')
->latest('contracts.id'); ->latest('contracts.id');
@@ -350,6 +433,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
->where('contract_segment.segment_id', '=', $segmentId) ->where('contract_segment.segment_id', '=', $segmentId)
->where('contract_segment.active', true); ->where('contract_segment.active', true);
}); });
} else {
// Only include contracts that have at least one active, non-excluded segment
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
->from('contract_segment')
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
->where('contract_segment.active', true)
->where('segments.exclude', false)
->whereColumn('contract_segment.contract_id', 'contracts.id')
);
} }
if ($q = trim((string) $request->input('q'))) { if ($q = trim((string) $request->input('q'))) {
@@ -399,13 +491,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
}); });
} }
$contracts = $query->get(); $contracts = $query->limit(500)->get();
$data = collect($contracts)->map(function (Contract $contract) use ($selector) { $data = collect($contracts)->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person; $person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person']; $selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
$phone = $selected['phone']; $phone = $selected['phone'];
$clientPerson = $contract->clientCase?->client?->person; $clientPerson = $contract->clientCase?->client?->person;
$segment = collect($contract->segments)->last();
return [ return [
'id' => $contract->id, 'id' => $contract->id,
@@ -423,6 +516,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
'uuid' => $person?->uuid, 'uuid' => $person?->uuid,
'full_name' => $person?->full_name, 'full_name' => $person?->full_name,
], ],
'segment' => $segment,
// Stranka: the client person // Stranka: the client person
'client' => $clientPerson ? [ 'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id, 'id' => $contract->clientCase?->client?->id,
@@ -434,13 +528,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
'number' => $phone->nu, 'number' => $phone->nu,
'validated' => $phone->validated, 'validated' => $phone->validated,
'type' => $phone->phone_type?->value, 'type' => $phone->phone_type?->value,
'description' => $phone->description,
] : null, ] : null,
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'), 'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
]; ];
}); });
return response()->json([ return response()->json([
'data' => $data 'data' => $data,
]); ]);
} }
@@ -530,6 +625,213 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
return back()->with('success', 'Package created from contracts'); return back()->with('success', 'Package created from contracts');
} }
/**
* List contracts with selected email per person (for email packages).
*/
public function contractsForEmail(Request $request, EmailSelector $selector): \Illuminate\Http\JsonResponse
{
$request->validate([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_verified' => ['nullable', 'boolean'],
'only_with_email' => ['nullable', 'boolean'],
'start_date_from' => ['nullable', 'date'],
'start_date_to' => ['nullable', 'date'],
'promise_date_from' => ['nullable', 'date'],
'promise_date_to' => ['nullable', 'date'],
]);
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
$query = Contract::query()
->with([
'clientCase.person.emails',
'clientCase.client.person',
'account',
'segments:id,name',
])
->select('contracts.*')
->latest('contracts.id');
if ($segmentId) {
$query->join('contract_segment', function ($j) use ($segmentId) {
$j->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.segment_id', '=', $segmentId)
->where('contract_segment.active', true);
});
} else {
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
->from('contract_segment')
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
->where('contract_segment.active', true)
->where('segments.exclude', false)
->whereColumn('contract_segment.contract_id', 'contracts.id')
);
}
if ($q = trim((string) $request->input('q'))) {
$query->where('contracts.reference', 'ILIKE', "%{$q}%");
}
if ($clientId = $request->integer('client_id')) {
$query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
->where('client_cases.client_id', $clientId);
}
if ($startDateFrom = $request->input('start_date_from')) {
$query->where('contracts.start_date', '>=', $startDateFrom);
}
if ($startDateTo = $request->input('start_date_to')) {
$query->where('contracts.start_date', '<=', $startDateTo);
}
$promiseDateFrom = $request->input('promise_date_from');
$promiseDateTo = $request->input('promise_date_to');
if ($promiseDateFrom || $promiseDateTo) {
$query->whereHas('account', function ($q) use ($promiseDateFrom, $promiseDateTo) {
if ($promiseDateFrom) {
$q->where('promise_date', '>=', $promiseDateFrom);
}
if ($promiseDateTo) {
$q->where('promise_date', '<=', $promiseDateTo);
}
});
}
if ($request->boolean('only_verified')) {
$query->whereHas('clientCase.person.emails', function ($q) {
$q->where('is_active', true)->whereNotNull('verified_at');
});
}
if ($request->boolean('only_with_email')) {
$query->whereHas('clientCase.person.emails', function ($q) {
$q->where('is_active', true);
});
}
$contracts = $query->limit(500)->get();
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['email' => null, 'reason' => 'no_person'];
$email = $selected['email'];
$clientPerson = $contract->clientCase?->client?->person;
$segment = collect($contract->segments)->last();
return [
'id' => $contract->id,
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'start_date' => $contract->start_date,
'promise_date' => $contract->account?->promise_date,
'case' => [
'id' => $contract->clientCase?->id,
'uuid' => $contract->clientCase?->uuid,
],
'person' => [
'id' => $person?->id,
'uuid' => $person?->uuid,
'full_name' => $person?->full_name,
],
'segment' => $segment,
'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id,
'uuid' => $contract->clientCase?->client?->uuid,
'name' => $clientPerson->full_name,
] : null,
'selected_email' => $email ? [
'id' => $email->id,
'value' => $email->value,
'is_primary' => $email->is_primary,
'verified' => $email->verified_at !== null,
'label' => $email->label,
] : null,
'no_email_reason' => $email ? null : ($selected['reason'] ?? 'unknown'),
];
});
return response()->json(['data' => $data]);
}
/**
* Create an email package from a list of contracts by selecting recipient emails.
*/
public function storeEmailFromContracts(StoreEmailPackageFromContractsRequest $request, EmailSelector $selector): RedirectResponse
{
$data = $request->validated();
$contracts = Contract::query()
->with(['clientCase.person', 'account.type'])
->whereIn('id', $data['contract_ids'])
->get();
$items = [];
$skipped = 0;
foreach ($contracts as $contract) {
$person = $contract->clientCase?->person;
if (! $person) {
$skipped++;
continue;
}
$selected = $selector->selectForPerson($person);
/** @var ?\App\Models\Email $email */
$email = $selected['email'];
if (! $email) {
$skipped++;
continue;
}
$items[] = [
'email' => $email->value,
'email_id' => $email->id,
'payload' => $data['payload'] ?? [],
'contract_id' => $contract->id,
'account_id' => $contract->account?->id,
];
}
if (empty($items)) {
return back()->with('error', 'No recipients found for selected contracts.');
}
$package = Package::query()->create([
'uuid' => (string) Str::uuid(),
'type' => Package::TYPE_EMAIL,
'status' => Package::STATUS_DRAFT,
'name' => $data['name'] ?? null,
'description' => $data['description'] ?? null,
'meta' => array_merge($data['meta'] ?? [], [
'source' => 'contracts',
'skipped' => $skipped,
]),
'created_by' => optional($request->user())->id,
]);
$packageItems = collect($items)->map(function (array $row) {
return new PackageItem([
'status' => 'queued',
'target_json' => [
'email' => $row['email'],
'email_id' => $row['email_id'],
'contract_id' => $row['contract_id'] ?? null,
'account_id' => $row['account_id'] ?? null,
],
'payload_json' => $row['payload'] ?? [],
]);
});
$package->items()->saveMany($packageItems);
$package->total_items = $packageItems->count();
$package->save();
return back()->with('success', 'Email package created from contracts');
}
/** /**
* Flatten nested meta structure into dot-notation key-value pairs. * Flatten nested meta structure into dot-notation key-value pairs.
* Extracts 'value' from objects with {title, value, type} structure. * Extracts 'value' from objects with {title, value, type} structure.
@@ -20,7 +20,7 @@ public function index(Request $request): Response
{ {
Gate::authorize('manage-settings'); Gate::authorize('manage-settings');
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active']); $users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active', 'login_redirect']);
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']); $roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']); $permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
@@ -73,4 +73,17 @@ public function toggleActive(User $user): RedirectResponse
return back()->with('success', "Uporabnik {$status}"); return back()->with('success', "Uporabnik {$status}");
} }
public function updateSettings(Request $request, User $user): RedirectResponse
{
Gate::authorize('manage-settings');
$validated = $request->validate([
'login_redirect' => ['nullable', 'string', 'max:255'],
]);
$user->update($validated);
return back()->with('success', 'Nastavitve shranjene');
}
} }
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\CallLater;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CallLaterController extends Controller
{
public function index(Request $request): \Inertia\Response
{
$query = CallLater::query()
->with([
'clientCase.person',
'contract',
'user',
'activity',
])
->whereNull('completed_at')
->orderBy('call_back_at', 'asc');
if ($request->filled('date_from')) {
$query->whereDate('call_back_at', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->whereDate('call_back_at', '<=', $request->date_to);
}
if ($request->filled('search')) {
$term = '%'.$request->search.'%';
$query->whereHas('clientCase.person', function ($q) use ($term) {
$q->where('first_name', 'ilike', $term)
->orWhere('last_name', 'ilike', $term)
->orWhere('full_name', 'ilike', $term)
->orWhereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", [$term]);
});
}
$callLaters = $query->paginate(50)->withQueryString();
return Inertia::render('CallLaters/Index', [
'callLaters' => $callLaters,
'filters' => $request->only(['date_from', 'date_to', 'search']),
]);
}
public function complete(CallLater $callLater): \Illuminate\Http\RedirectResponse
{
$callLater->update(['completed_at' => now()]);
return back()->with('success', 'Klic označen kot opravljen.');
}
}
+219 -17
View File
@@ -71,10 +71,8 @@ public function index(ClientCase $clientCase, Request $request)
$que->whereDate('client_cases.created_at', '<=', $to); $que->whereDate('client_cases.created_at', '<=', $to);
}) })
->groupBy('client_cases.id') ->groupBy('client_cases.id')
->addSelect([ ->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'), ->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->with(['person.client', 'client.person']) ->with(['person.client', 'client.person'])
->orderByDesc('client_cases.created_at'); ->orderByDesc('client_cases.created_at');
@@ -223,7 +221,11 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
return back()->with('warning', __('contracts.edit_not_allowed_archived')); return back()->with('warning', __('contracts.edit_not_allowed_archived'));
} }
\DB::transaction(function () use ($request, $contract) { $balanceChanged = false;
$oldBalance = null;
$newBalance = null;
\DB::transaction(function () use ($request, $contract, &$balanceChanged, &$oldBalance, &$newBalance) {
$contract->update([ $contract->update([
'reference' => $request->input('reference'), 'reference' => $request->input('reference'),
'type_id' => $request->input('type_id'), 'type_id' => $request->input('type_id'),
@@ -254,6 +256,7 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
$accountData['type_id'] = $request->input('account_type_id'); $accountData['type_id'] = $request->input('account_type_id');
} }
if ($currentAccount) { if ($currentAccount) {
$oldBalance = (float) $currentAccount->balance_amount;
$currentAccount->update($accountData); $currentAccount->update($accountData);
if (array_key_exists('balance_amount', $accountData)) { if (array_key_exists('balance_amount', $accountData)) {
$currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save(); $currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save();
@@ -264,6 +267,10 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]); ->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]);
$freshBal = (float) optional($currentAccount->fresh())->balance_amount; $freshBal = (float) optional($currentAccount->fresh())->balance_amount;
} }
$newBalance = $freshBal;
if ($oldBalance !== $freshBal) {
$balanceChanged = true;
}
} else { } else {
$freshBal = (float) optional($currentAccount->fresh())->balance_amount; $freshBal = (float) optional($currentAccount->fresh())->balance_amount;
} }
@@ -276,6 +283,27 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
}); });
// Fire activity if balance changed and settings require it
if ($balanceChanged) {
$contractSetting = \App\Models\ContractSetting::query()->first();
if ($contractSetting && $contractSetting->create_activity_on_balance_change) {
$note = str_replace(
['{old_balance}', '{new_balance}', '{currency}'],
[number_format($oldBalance, 2, '.', ''), number_format($newBalance, 2, '.', ''), 'EUR'],
$contractSetting->activity_note_template ?? ''
);
\App\Models\Activity::query()->create([
'due_date' => null,
'amount' => $newBalance,
'note' => $note,
'action_id' => $contractSetting->default_action_id,
'decision_id' => $contractSetting->default_decision_id,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
]);
}
}
// Preserve segment filter if present // Preserve segment filter if present
$segment = request('segment'); $segment = request('segment');
@@ -306,6 +334,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
try { try {
$attributes = $request->validate([ $attributes = $request->validate([
'due_date' => 'nullable|date', 'due_date' => 'nullable|date',
'call_back_at' => 'nullable|date_format:Y-m-d H:i:s|after_or_equal:now',
'amount' => 'nullable|decimal:0,4', 'amount' => 'nullable|decimal:0,4',
'note' => 'nullable|string', 'note' => 'nullable|string',
'action_id' => 'exists:\App\Models\Action,id', 'action_id' => 'exists:\App\Models\Action,id',
@@ -371,6 +400,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
// Create activity // Create activity
$row = $clientCase->activities()->create([ $row = $clientCase->activities()->create([
'due_date' => $attributes['due_date'] ?? null, 'due_date' => $attributes['due_date'] ?? null,
'call_back_at' => $attributes['call_back_at'] ?? null,
'amount' => $attributes['amount'] ?? null, 'amount' => $attributes['amount'] ?? null,
'note' => $attributes['note'] ?? null, 'note' => $attributes['note'] ?? null,
'action_id' => $attributes['action_id'], 'action_id' => $attributes['action_id'],
@@ -602,9 +632,9 @@ public function storeDocument(ClientCase $clientCase, Request $request)
$contract = null; $contract = null;
if (! empty($validated['contract_uuid'])) { if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first(); $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
if ($contract && ! $contract->active) { /*if ($contract && ! $contract->active) {
return back()->with('warning', __('contracts.document_not_allowed_archived')); return back()->with('warning', __('contracts.document_not_allowed_archived'));
} }*/
} }
$directory = $contract $directory = $contract
? ('contracts/'.$contract->uuid.'/documents') ? ('contracts/'.$contract->uuid.'/documents')
@@ -825,9 +855,8 @@ public function show(ClientCase $clientCase)
} }
// Get contracts using service // Get contracts using service
$contractsPerPage = request()->integer('contracts_per_page', 10); $contracts = $this->caseDataService->getContracts($case, $segmentId);
$contracts = $this->caseDataService->getContracts($case, $segmentId, $contractsPerPage); $contractIds = collect($contracts)->pluck('id')->all();
$contractIds = collect($contracts->items())->pluck('id')->all();
// Get activities using service // Get activities using service
$activitiesPerPage = request()->integer('activities_per_page', 15); $activitiesPerPage = request()->integer('activities_per_page', 15);
@@ -868,11 +897,14 @@ public function show(ClientCase $clientCase)
'decisions.emailTemplate' => function ($q) { 'decisions.emailTemplate' => function ($q) {
$q->select('id', 'name', 'entity_types', 'allow_attachments'); $q->select('id', 'name', 'entity_types', 'allow_attachments');
}, },
'decisions.events' => function ($q) {
$q->select('events.id', 'events.key', 'events.name');
},
]) ])
->get(['id', 'name', 'color_tag', 'segment_id']), ->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' => Segment::query()->where('active', true)->get(['id', 'name']),
'current_segment' => $currentSegment, 'current_segment' => $currentSegment,
'sms_profiles' => \App\Models\SmsProfile::query() 'sms_profiles' => \App\Models\SmsProfile::query()
->select(['id', 'name', 'default_sender_id']) ->select(['id', 'name', 'default_sender_id'])
@@ -881,14 +913,27 @@ public function show(ClientCase $clientCase)
->get(), ->get(),
'sms_senders' => \App\Models\SmsSender::query() 'sms_senders' => \App\Models\SmsSender::query()
->select(['id', 'profile_id']) ->select(['id', 'profile_id'])
->addSelect(\DB::raw('sname as name')) ->selectRaw('sname as name')
->addSelect(\DB::raw('phone_number as phone')) ->selectRaw('phone_number as phone')
->orderBy('sname') ->orderBy('sname')
->get(), ->get(),
'sms_templates' => \App\Models\SmsTemplate::query() 'sms_templates' => \App\Models\SmsTemplate::query()
->select(['id', 'name', 'content', 'allow_custom_body']) ->select(['id', 'name', 'content', 'allow_custom_body'])
->orderBy('name') ->orderBy('name')
->get(), ->get(),
'email_templates' => \App\Models\EmailTemplate::query()
->select(['id', 'name', 'subject_template', 'text_template', 'action_id', 'decision_id'])
->where('active', true)
->where('client', false)
->orderBy('name')
->get(),
'mail_profiles' => \App\Models\MailProfile::query()
->select(['id', 'name'])
->where('active', true)
->orderBy('priority')
->orderBy('name')
->get(),
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
]); ]);
} }
@@ -1102,6 +1147,7 @@ public function archiveBatch(Request $request)
if (! $setting) { if (! $setting) {
\Log::warning('No archive settings found for batch archive'); \Log::warning('No archive settings found for batch archive');
return back()->with('flash', [ return back()->with('flash', [
'error' => 'No archive settings found', 'error' => 'No archive settings found',
]); ]);
@@ -1119,6 +1165,7 @@ public function archiveBatch(Request $request)
// Skip if contract is already archived (active = 0) // Skip if contract is already archived (active = 0)
if (! $contract->active) { if (! $contract->active) {
$skippedCount++; $skippedCount++;
continue; continue;
} }
@@ -1208,7 +1255,7 @@ public function archiveBatch(Request $request)
if ($skippedCount > 0) { if ($skippedCount > 0) {
$message .= ", skipped $skippedCount already archived"; $message .= ", skipped $skippedCount already archived";
} }
$message .= ", " . count($errors) . " failed"; $message .= ', '.count($errors).' failed';
return back()->with('flash', [ return back()->with('flash', [
'error' => $message, 'error' => $message,
@@ -1345,10 +1392,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
if (! empty($validated['sender_id'])) { if (! empty($validated['sender_id'])) {
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']); $sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
if (! $sender) { if (! $sender) {
return back()->with('error', 'Izbran po┼íiljatelj ne obstaja.'); return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
} }
if ($profile && (int) $sender->profile_id !== (int) $profile->id) { if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
return back()->with('error', 'Izbran po┼íiljatelj ne pripada izbranemu profilu.'); return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
} }
} }
if (! $profile) { if (! $profile) {
@@ -1391,7 +1438,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
} }
// Create an activity before sending // Create an activity before sending
$activityNote = sprintf('┼át: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']); $activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
$activityData = [ $activityData = [
'note' => $activityNote, 'note' => $activityNote,
'user_id' => optional($request->user())->id, 'user_id' => optional($request->user())->id,
@@ -1540,6 +1587,161 @@ public function previewSms(ClientCase $clientCase, Request $request, SmsService
* Extracts 'value' from objects with {title, value, type} structure. * Extracts 'value' from objects with {title, value, type} structure.
* Also creates direct access aliases for nested fields (skipping numeric keys). * Also creates direct access aliases for nested fields (skipping numeric keys).
*/ */
/**
* Render an email template preview with context from the client case.
*/
public function previewEmailForEmail(ClientCase $clientCase, Request $request, int $email_id): \Illuminate\Http\JsonResponse
{
$validated = $request->validate([
'template_id' => ['required', 'integer', 'exists:email_templates,id'],
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
'body_text' => ['sometimes', 'nullable', 'string', 'max:10000'],
]);
$email = \App\Models\Email::query()
->where('id', $email_id)
->where('person_id', $clientCase->person_id)
->firstOrFail();
$template = \App\Models\EmailTemplate::findOrFail((int) $validated['template_id']);
$contract = null;
if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()
->where('uuid', $validated['contract_uuid'])
->first();
}
$ctx = $this->buildCaseEmailContext($clientCase, $contract);
$ctx['body_text'] = (string) ($validated['body_text'] ?? '');
$renderer = app(\App\Services\EmailTemplateRenderer::class);
$rendered = $renderer->render([
'subject' => (string) $template->subject_template,
'html' => (string) $template->html_template,
'text' => (string) $template->text_template,
], $ctx);
return response()->json([
'subject' => $rendered['subject'] ?? '',
'html' => (string) ($rendered['html'] ?? ''),
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $template->html_template),
]);
}
/**
* Send a (possibly templated) email to a person email address belonging to this case.
*/
public function sendEmailToEmail(ClientCase $clientCase, Request $request, int $email_id)
{
$validated = $request->validate([
'subject' => ['required', 'string', 'max:255'],
'html_body' => ['nullable', 'string'],
'body_text' => ['nullable', 'string', 'max:10000'],
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:email_templates,id'],
'mail_profile_id' => ['sometimes', 'nullable', 'integer', 'exists:mail_profiles,id'],
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
]);
// Ensure the email belongs to the person of this case
$email = \App\Models\Email::query()
->where('id', $email_id)
->where('person_id', $clientCase->person_id)
->firstOrFail();
$to = (string) $email->value;
/** @var \App\Models\MailProfile|null $mailProfile */
$mailProfile = ! empty($validated['mail_profile_id'])
? \App\Models\MailProfile::query()->where('id', $validated['mail_profile_id'])->where('active', true)->first()
: \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
if (! $mailProfile) {
return back()->with('error', 'Ni aktivnega e-poštnega profila.');
}
$contract = null;
if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
}
$htmlBody = (string) ($validated['html_body'] ?? '');
$bodyText = (string) ($validated['body_text'] ?? '');
// Apply {{body_text}} substitution if the html body contains the placeholder
if ($bodyText !== '' && preg_match('/{{\s*body_text\s*}}/', $htmlBody)) {
$renderer = app(\App\Services\EmailTemplateRenderer::class);
$htmlBody = $renderer->applyBodyText($htmlBody, $bodyText, html: true) ?? $htmlBody;
}
$subject = (string) $validated['subject'];
$log = new \App\Models\EmailLog;
$log->fill([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'template_id' => $validated['template_id'] ?? null,
'mail_profile_id' => $mailProfile->id,
'to_email' => $to,
'to_recipients' => [$to],
'subject' => $subject,
'body_html_hash' => $htmlBody !== '' ? hash('sha256', $htmlBody) : null,
'body_text_preview' => null,
'embed_mode' => 'base64',
'status' => \App\Models\EmailLogStatus::Queued,
'queued_at' => now(),
'client_id' => $clientCase->client_id,
'client_case_id' => $clientCase->id,
'contract_id' => $contract?->id,
'ip' => $request->ip(),
]);
$log->save();
$log->body()->create([
'body_html' => $htmlBody,
'body_text' => $bodyText,
'inline_css' => false,
]);
dispatch(new \App\Jobs\SendEmailTemplateJob($log->id));
// Create activity if template has action/decision
if (! empty($validated['template_id'])) {
$template = \App\Models\EmailTemplate::find((int) $validated['template_id']);
if ($template && ($template->action_id || $template->decision_id)) {
$activity = $clientCase->activities()->create(array_filter([
'contract_id' => $contract?->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => 'Poslano: '.$to.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
'user_id' => optional($request->user())->id,
], fn ($v) => ! is_null($v)));
$activity->emailLogs()->attach($log->id);
}
}
return back()->with('success', "E-pošta poslana na {$to}.");
}
/**
* Build a template rendering context from the given client case and optional contract.
*/
private function buildCaseEmailContext(ClientCase $clientCase, ?\App\Models\Contract $contract = null): array
{
$clientCase->loadMissing('client.person');
$ctx = [
'client_case' => $clientCase,
'client' => $clientCase->client,
'person' => optional($clientCase->client)->person,
'mail_profile' => \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(),
];
if ($contract) {
$contract->loadMissing(['clientCase.client.person', 'account.type']);
$ctx['contract'] = $contract;
}
return $ctx;
}
private function flattenMeta(array $meta, string $prefix = ''): array private function flattenMeta(array $meta, string $prefix = ''): array
{ {
$result = []; $result = [];
+6 -10
View File
@@ -40,12 +40,8 @@ public function index(Client $client, Request $request)
}) })
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id') ->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('clients.id') ->groupBy('clients.id')
->addSelect([ ->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count')
// Number of client cases for this client that have at least one active contract ->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
// Sum of account balances for active contracts
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->with('person') ->with('person')
->orderByDesc('clients.created_at'); ->orderByDesc('clients.created_at');
@@ -71,6 +67,7 @@ public function show(Client $client, Request $request)
return Inertia::render('Client/Show', [ return Inertia::render('Client/Show', [
'client' => $data, 'client' => $data,
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
'client_cases' => $data->clientCases() 'client_cases' => $data->clientCases()
->select('client_cases.*') ->select('client_cases.*')
->when($request->input('search'), function ($que, $search) { ->when($request->input('search'), function ($que, $search) {
@@ -88,10 +85,8 @@ public function show(Client $client, Request $request)
}) })
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id') ->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('client_cases.id') ->groupBy('client_cases.id')
->addSelect([ ->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'), ->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->with(['person', 'client.person']) ->with(['person', 'client.person'])
->where('client_cases.active', 1) ->where('client_cases.active', 1)
->orderByDesc('client_cases.created_at') ->orderByDesc('client_cases.created_at')
@@ -162,6 +157,7 @@ public function contracts(Client $client, Request $request)
return Inertia::render('Client/Contracts', [ return Inertia::render('Client/Contracts', [
'client' => $data, 'client' => $data,
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
'contracts' => $contractsQuery 'contracts' => $contractsQuery
->paginate($perPage, ['*'], 'contracts_page', $pageNumber) ->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
->withQueryString(), ->withQueryString(),
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
class ContractSettingController extends Controller
{
public function edit(): \Inertia\Response
{
$setting = \App\Models\ContractSetting::query()->first();
if (! $setting) {
$setting = \App\Models\ContractSetting::query()->create([
'create_activity_on_balance_change' => false,
'default_action_id' => null,
'default_decision_id' => null,
'activity_note_template' => 'Sprememba stanja pogodbe: {old_balance} → {new_balance} {currency}',
]);
}
$decisions = \App\Models\Decision::query()->orderBy('name')->get(['id', 'name']);
$actions = \App\Models\Action::query()
->with(['decisions:id'])
->orderBy('name')
->get()
->map(function (\App\Models\Action $a) {
return [
'id' => $a->id,
'name' => $a->name,
'decision_ids' => $a->decisions->pluck('id')->values(),
];
});
return \Inertia\Inertia::render('Settings/Contracts/Index', [
'setting' => [
'id' => $setting->id,
'create_activity_on_balance_change' => (bool) $setting->create_activity_on_balance_change,
'default_action_id' => $setting->default_action_id,
'default_decision_id' => $setting->default_decision_id,
'activity_note_template' => $setting->activity_note_template,
],
'decisions' => $decisions,
'actions' => $actions,
]);
}
public function update(\App\Http\Requests\UpdateContractSettingRequest $request): \Illuminate\Http\RedirectResponse
{
$data = $request->validated();
$setting = \App\Models\ContractSetting::query()->firstOrFail();
$data['create_activity_on_balance_change'] = (bool) ($data['create_activity_on_balance_change'] ?? false);
$setting->update($data);
return back()->with('success', 'Nastavitve shranjene.');
}
}
+13 -8
View File
@@ -14,7 +14,6 @@
use App\Services\Sms\SmsService; use App\Services\Sms\SmsService;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -80,14 +79,14 @@ public function __invoke(SmsService $sms): Response
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d')); ->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end]) $fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c') ->selectRaw("DATE(COALESCE(assigned_at, created_at) AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
->groupBy('d') ->groupBy('d')
->pluck('c', 'd'); ->pluck('c', 'd');
// Completed field jobs last 7 days // Completed field jobs last 7 days
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at') $fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
->whereBetween('completed_at', [$start, $end]) ->whereBetween('completed_at', [$start, $end])
->selectRaw('DATE(completed_at) as d, COUNT(*) as c') ->selectRaw("DATE(completed_at AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
->groupBy('d') ->groupBy('d')
->pluck('c', 'd'); ->pluck('c', 'd');
@@ -101,13 +100,13 @@ public function __invoke(SmsService $sms): Response
// Field jobs assigned today - cached // Field jobs assigned today - cached
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) { $fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
return FieldJob::query() return FieldJob::query()
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today) ->whereRaw('DATE(COALESCE(assigned_at, created_at)) = ?', [$today->toDateString()])
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id']) ->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
->with(['contract' => function ($q) { ->with(['contract' => function ($q) {
$q->select('id', 'uuid', 'reference', 'client_case_id') $q->select('id', 'uuid', 'reference', 'client_case_id')
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']); ->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
}]) }])
->latest(DB::raw('COALESCE(assigned_at, created_at)')) ->orderByRaw('COALESCE(assigned_at, created_at) DESC')
->limit(15) ->limit(15)
->get() ->get()
->map(function ($fj) { ->map(function ($fj) {
@@ -120,20 +119,26 @@ public function __invoke(SmsService $sms): Response
} }
} }
if (! $contract) {
return null;
}
return [ return [
'id' => $fj->id, 'id' => $fj->id,
'priority' => $fj->priority, 'priority' => $fj->priority,
'assigned_at' => $fj->assigned_at?->toIso8601String(), 'assigned_at' => $fj->assigned_at?->toIso8601String(),
'created_at' => $fj->created_at?->toIso8601String(), 'created_at' => $fj->created_at?->toIso8601String(),
'contract' => $contract ? [ 'contract' => [
'uuid' => $contract->uuid, 'uuid' => $contract->uuid,
'reference' => $contract->reference, 'reference' => $contract->reference,
'client_case_uuid' => optional($contract->clientCase)->uuid, 'client_case_uuid' => optional($contract->clientCase)->uuid,
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name, 'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
'segment_id' => $segmentId, 'segment_id' => $segmentId,
] : null, ],
]; ];
}); })
->filter()
->values();
}); });
// System health for timestamp // System health for timestamp
+17 -3
View File
@@ -9,7 +9,6 @@
use App\Models\ImportEvent; use App\Models\ImportEvent;
use App\Models\ImportTemplate; use App\Models\ImportTemplate;
use App\Services\CsvImportService; use App\Services\CsvImportService;
use App\Services\Import\ImportServiceV2;
use App\Services\Import\ImportSimulationServiceV2; use App\Services\Import\ImportSimulationServiceV2;
use App\Services\ImportProcessor; use App\Services\ImportProcessor;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -65,6 +64,7 @@ public function index(Request $request)
'current_page' => $paginator->currentPage(), 'current_page' => $paginator->currentPage(),
'from' => $paginator->firstItem(), 'from' => $paginator->firstItem(),
'last_page' => $paginator->lastPage(), 'last_page' => $paginator->lastPage(),
'links' => $paginator->linkCollection()->toArray(),
'path' => $paginator->path(), 'path' => $paginator->path(),
'per_page' => $paginator->perPage(), 'per_page' => $paginator->perPage(),
'to' => $paginator->lastItem(), 'to' => $paginator->lastItem(),
@@ -190,6 +190,7 @@ public function process(Import $import, Request $request, ImportProcessor $proce
try { try {
$result = $processor->process($import, user: $request->user()); $result = $processor->process($import, user: $request->user());
return response()->json($result); return response()->json($result);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::error('Import processing failed', [ \Log::error('Import processing failed', [
@@ -712,8 +713,6 @@ public function simulatePayments(Import $import, Request $request)
* templates. For payments templates, payment-specific summaries/entities will be included * templates. For payments templates, payment-specific summaries/entities will be included
* automatically by the simulation service when mappings contain the payment root. * automatically by the simulation service when mappings contain the payment root.
* *
* @param Import $import
* @param Request $request
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*/ */
public function simulate(Import $import, Request $request) public function simulate(Import $import, Request $request)
@@ -829,4 +828,19 @@ public function destroy(Request $request, Import $import)
return back()->with('success', 'Import deleted successfully'); return back()->with('success', 'Import deleted successfully');
} }
// Download the original import file
public function download(Import $import)
{
// Verify file exists
if (! $import->disk || ! $import->path || ! Storage::disk($import->disk)->exists($import->path)) {
return response()->json([
'error' => 'File not found',
], 404);
}
$fileName = $import->original_name ?? 'import_'.$import->uuid;
return Storage::disk($import->disk)->download($import->path, $fileName);
}
} }
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateInstallmentSettingRequest;
use App\Models\Action;
use App\Models\Decision;
use App\Models\InstallmentSetting;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class InstallmentSettingController extends Controller
{
public function edit(): Response
{
$setting = InstallmentSetting::query()->first();
if (! $setting) {
$setting = InstallmentSetting::query()->create([
'default_currency' => 'EUR',
'create_activity_on_installment' => false,
'default_decision_id' => null,
'default_action_id' => null,
'activity_note_template' => 'Dodan obrok: {amount} {currency}',
]);
}
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
$actions = Action::query()
->with(['decisions:id'])
->orderBy('name')
->get()
->map(function (Action $a) {
return [
'id' => $a->id,
'name' => $a->name,
'decision_ids' => $a->decisions->pluck('id')->values(),
];
});
return Inertia::render('Settings/Installments/Index', [
'setting' => [
'id' => $setting->id,
'default_currency' => $setting->default_currency,
'create_activity_on_installment' => (bool) $setting->create_activity_on_installment,
'default_decision_id' => $setting->default_decision_id,
'default_action_id' => $setting->default_action_id,
'activity_note_template' => $setting->activity_note_template,
],
'decisions' => $decisions,
'actions' => $actions,
]);
}
public function update(UpdateInstallmentSettingRequest $request): RedirectResponse
{
$data = $request->validated();
$setting = InstallmentSetting::query()->firstOrFail();
$data['create_activity_on_installment'] = (bool) ($data['create_activity_on_installment'] ?? false);
$setting->update($data);
return back()->with('success', 'Nastavitve shranjene.');
}
}
+16 -7
View File
@@ -2,7 +2,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\BankAccount;
use App\Models\Person\Person; use App\Models\Person\Person;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -22,14 +21,14 @@ public function update(Person $person, Request $request)
'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',
'employer' => 'nullable|string|max:255',
'birthday' => 'nullable|date',
]); ]);
$person->update($attributes); $person->update($attributes);
return back()->with('success', 'Person updated')->with('flash_method', 'PUT'); return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
} }
public function createAddress(Person $person, Request $request) public function createAddress(Person $person, Request $request)
@@ -80,7 +79,6 @@ 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 back()->with('success', 'Address deleted')->with('flash_method', 'DELETE'); return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
} }
@@ -138,12 +136,19 @@ public function createEmail(Person $person, Request $request)
'is_primary' => 'boolean', 'is_primary' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
'valid' => 'boolean', 'valid' => 'boolean',
'failed' => 'boolean',
'receive_auto_mails' => 'sometimes|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',
'decision_ids' => 'nullable|array',
'decision_ids.*' => 'integer|exists:decisions,id',
]); ]);
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
unset($attributes['decision_ids']);
$attributes['preferences'] = array_merge($attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
// Dedup: avoid duplicate email per person by value // Dedup: avoid duplicate email per person by value
$email = $person->emails()->firstOrCreate([ $email = $person->emails()->firstOrCreate([
'value' => $attributes['value'], 'value' => $attributes['value'],
@@ -160,14 +165,21 @@ 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',
'failed' => 'boolean',
'receive_auto_mails' => 'sometimes|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',
'decision_ids' => 'nullable|array',
'decision_ids.*' => 'integer|exists:decisions,id',
]); ]);
$email = $person->emails()->findOrFail($email_id); $email = $person->emails()->findOrFail($email_id);
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
unset($attributes['decision_ids']);
$attributes['preferences'] = array_merge($email->preferences ?? [], $attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
$email->update($attributes); $email->update($attributes);
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT'); return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
@@ -204,10 +216,8 @@ public function createTrr(Person $person, Request $request)
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided // Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
$trr = $person->bankAccounts()->create($attributes); $trr = $person->bankAccounts()->create($attributes);
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST'); return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
} }
public function updateTrr(Person $person, int $trr_id, Request $request) public function updateTrr(Person $person, int $trr_id, Request $request)
@@ -238,7 +248,6 @@ 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 back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE'); return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
} }
+23 -24
View File
@@ -10,19 +10,14 @@
class PhoneViewController extends Controller class PhoneViewController extends Controller
{ {
public function __construct(protected ReferenceDataCache $referenceCache) {} public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Request $request)
public function index(Request $request): \Inertia\Response
{ {
$userId = $request->user()->id; $userId = $request->user()->id;
$search = $request->input('search'); $search = $request->input('search');
$clientFilter = $request->input('client'); $clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$query = FieldJob::query() $eagerLoad = [
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->with([
'contract' => function ($q) { 'contract' => function ($q) {
$q->with([ $q->with([
'type:id,name', 'type:id,name',
@@ -33,19 +28,22 @@ public function index(Request $request)
'clientCase.client.person:id,full_name', 'clientCase.client.person:id,full_name',
]); ]);
}, },
]) ];
->orderByDesc('assigned_at');
$baseQuery = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->with($eagerLoad);
// Apply client filter
if ($clientFilter) { if ($clientFilter) {
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) { $baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
$q->where('uuid', $clientFilter); $q->where('uuid', $clientFilter);
}); });
} }
// Apply search filter
if ($search) { if ($search) {
$query->where(function ($q) use ($search) { $baseQuery->where(function ($q) use ($search) {
$q->whereHas('contract', function ($cq) use ($search) { $q->whereHas('contract', function ($cq) use ($search) {
$cq->where('reference', 'ilike', '%'.$search.'%') $cq->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($pq) use ($search) { ->orWhereHas('clientCase.person', function ($pq) use ($search) {
@@ -58,9 +56,14 @@ public function index(Request $request)
}); });
} }
$jobs = $query->paginate($perPage)->withQueryString(); $pendingQuery = (clone $baseQuery)
->where(fn ($q) => $q->where('added_activity', false)->orWhereNull('added_activity'))
->orderByDesc('assigned_at');
$processedQuery = (clone $baseQuery)
->where('added_activity', true)
->orderByDesc('assigned_at');
// Get unique clients for filter dropdown
$clients = \App\Models\Client::query() $clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) { ->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
$q->where('assigned_user_id', $userId) $q->where('assigned_user_id', $userId)
@@ -77,7 +80,8 @@ public function index(Request $request)
->values(); ->values();
return Inertia::render('Phone/Index', [ return Inertia::render('Phone/Index', [
'jobs' => $jobs, 'pendingJobs' => Inertia::scroll(fn () => $pendingQuery->paginate(15, pageName: 'pending')),
'processedJobs' => Inertia::scroll(fn () => $processedQuery->paginate(15, pageName: 'processed')),
'clients' => $clients, 'clients' => $clients,
'view_mode' => 'assigned', 'view_mode' => 'assigned',
'filters' => [ 'filters' => [
@@ -87,13 +91,11 @@ public function index(Request $request)
]); ]);
} }
public function completedToday(Request $request) public function completedToday(Request $request): \Inertia\Response
{ {
$userId = $request->user()->id; $userId = $request->user()->id;
$search = $request->input('search'); $search = $request->input('search');
$clientFilter = $request->input('client'); $clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$start = now()->startOfDay(); $start = now()->startOfDay();
$end = now()->endOfDay(); $end = now()->endOfDay();
@@ -138,9 +140,6 @@ public function completedToday(Request $request)
}); });
} }
$jobs = $query->paginate($perPage)->withQueryString();
// Get unique clients for filter dropdown
$clients = \App\Models\Client::query() $clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) { ->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
$q->where('assigned_user_id', $userId) $q->where('assigned_user_id', $userId)
@@ -157,7 +156,7 @@ public function completedToday(Request $request)
->values(); ->values();
return Inertia::render('Phone/Index', [ return Inertia::render('Phone/Index', [
'jobs' => $jobs, 'completedJobs' => Inertia::scroll(fn () => $query->paginate(15, pageName: 'completed')),
'clients' => $clients, 'clients' => $clients,
'view_mode' => 'completed-today', 'view_mode' => 'completed-today',
'filters' => [ 'filters' => [
+38 -1
View File
@@ -281,7 +281,7 @@ public function clients(Request $request)
->get() ->get()
->map(fn ($c) => [ ->map(fn ($c) => [
'id' => $c->uuid, 'id' => $c->uuid,
'name' => $c->person->full_name ?? 'Unknown' 'name' => $c->person->full_name ?? 'Unknown',
]) ])
->sortBy('name') ->sortBy('name')
->values(); ->values();
@@ -289,6 +289,41 @@ public function clients(Request $request)
return response()->json($clients); return response()->json($clients);
} }
/**
* Lightweight actions lookup for select:action filters.
*/
public function actions(Request $request)
{
$actions = \App\Models\Action::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn ($a) => ['id' => $a->id, 'name' => $a->name])
->values();
return response()->json($actions);
}
/**
* Lightweight decisions lookup for select:decision filters.
* Optionally filtered by action_id (for dependent filter UI).
*/
public function decisions(Request $request)
{
$actionId = $request->integer('action_id', 0) ?: null;
$q = \App\Models\Decision::query()->orderBy('name');
if ($actionId !== null) {
$q->whereHas('actions', fn ($qq) => $qq->where('actions.id', $actionId));
}
$decisions = $q->get(['id', 'name'])
->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])
->values();
return response()->json($decisions);
}
/** /**
* Build validation rules based on inputs descriptor and validate. * Build validation rules based on inputs descriptor and validate.
* *
@@ -307,6 +342,8 @@ protected function validateFilters(array $inputs, Request $request): array
'integer' => [$nullable, 'integer'], 'integer' => [$nullable, 'integer'],
'select:user' => [$nullable, 'integer', 'exists:users,id'], 'select:user' => [$nullable, 'integer', 'exists:users,id'],
'select:client' => [$nullable, 'string', 'exists:clients,uuid'], 'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
'select:action' => [$nullable, 'integer', 'exists:actions,id'],
'select:decision' => [$nullable, 'integer', 'exists:decisions,id'],
default => [$nullable, 'string'], default => [$nullable, 'string'],
}; };
} }
+29 -4
View File
@@ -7,6 +7,7 @@
use App\Models\Decision; use App\Models\Decision;
use App\Models\EmailTemplate; use App\Models\EmailTemplate;
use App\Models\Segment; use App\Models\Segment;
use App\Services\DecisionEvents\ConditionEvaluator;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
@@ -22,6 +23,8 @@ public function index(Request $request)
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']), 'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']), 'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']), 'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
'condition_fields' => ConditionEvaluator::availableFields(),
'condition_operators' => ConditionEvaluator::availableOperators(),
]); ]);
} }
@@ -83,6 +86,9 @@ public function updateAction(int $id, Request $request)
public function storeDecision(Request $request) public function storeDecision(Request $request)
{ {
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
$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',
@@ -96,6 +102,14 @@ public function storeDecision(Request $request)
'events.*.active' => 'sometimes|boolean', 'events.*.active' => 'sometimes|boolean',
'events.*.run_order' => 'nullable|integer', 'events.*.run_order' => 'nullable|integer',
'events.*.config' => 'nullable|array', 'events.*.config' => 'nullable|array',
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
'events.*.config.deactivate_previous' => 'sometimes|boolean',
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
'events.*.config.reactivate' => 'sometimes|boolean',
'events.*.config.conditions' => 'nullable|array',
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
]); ]);
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray(); $actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
@@ -112,12 +126,12 @@ public function storeDecision(Request $request)
$key = $eventModel?->key ?? ($ev['key'] ?? null); $key = $eventModel?->key ?? ($ev['key'] ?? null);
if ($key === 'add_segment') { if ($key === 'add_segment') {
$seg = $ev['config']['segment_id'] ?? null; $seg = $ev['config']['segment_id'] ?? null;
if (empty($seg) || ! Segment::where('id', $seg)->exists()) { if (empty($seg)) {
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.'; $validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
} }
} elseif ($key === 'archive_contract') { } elseif ($key === 'archive_contract') {
$as = $ev['config']['archive_setting_id'] ?? null; $as = $ev['config']['archive_setting_id'] ?? null;
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) { if (empty($as)) {
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.'; $validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
} }
} }
@@ -174,6 +188,9 @@ public function updateDecision(int $id, Request $request)
{ {
$row = Decision::findOrFail($id); $row = Decision::findOrFail($id);
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
$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',
@@ -187,6 +204,14 @@ public function updateDecision(int $id, Request $request)
'events.*.active' => 'sometimes|boolean', 'events.*.active' => 'sometimes|boolean',
'events.*.run_order' => 'nullable|integer', 'events.*.run_order' => 'nullable|integer',
'events.*.config' => 'nullable|array', 'events.*.config' => 'nullable|array',
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
'events.*.config.deactivate_previous' => 'sometimes|boolean',
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
'events.*.config.reactivate' => 'sometimes|boolean',
'events.*.config.conditions' => 'nullable|array',
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
]); ]);
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray(); $actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
@@ -203,12 +228,12 @@ public function updateDecision(int $id, Request $request)
$key = $eventModel?->key ?? ($ev['key'] ?? null); $key = $eventModel?->key ?? ($ev['key'] ?? null);
if ($key === 'add_segment') { if ($key === 'add_segment') {
$seg = $ev['config']['segment_id'] ?? null; $seg = $ev['config']['segment_id'] ?? null;
if (empty($seg) || ! Segment::where('id', $seg)->exists()) { if (empty($seg)) {
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.'; $validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
} }
} elseif ($key === 'archive_contract') { } elseif ($key === 'archive_contract') {
$as = $ev['config']['archive_setting_id'] ?? null; $as = $ev['config']['archive_setting_id'] ?? null;
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) { if (empty($as)) {
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.'; $validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
} }
} }
@@ -59,6 +59,15 @@ public function share(Request $request): array
'info' => fn () => $request->session()->get('info'), 'info' => fn () => $request->session()->get('info'),
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling 'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
], ],
'callLaterCount' => function () use ($request) {
if (! $request->user()) {
return 0;
}
return \App\Models\CallLater::query()
->whereNull('completed_at')
->count();
},
'notifications' => function () use ($request) { 'notifications' => function () use ($request) {
try { try {
$user = $request->user(); $user = $request->user();
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEmailPackageFromContractsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'type' => ['required', 'in:email'],
'name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'meta' => ['nullable', 'array'],
// Common payload for all items
'payload' => ['required', 'array'],
'payload.mail_profile_id' => ['nullable', 'integer', 'exists:mail_profiles,id'],
'payload.template_id' => ['nullable', 'integer', 'exists:email_templates,id'],
'payload.subject' => ['nullable', 'string', 'max:255'],
'payload.body_text' => ['nullable', 'string', 'max:10000'],
'payload.variables' => ['nullable', 'array'],
// Source contracts to derive items from
'contract_ids' => ['required', 'array', 'min:1'],
'contract_ids.*' => ['integer', 'exists:contracts,id'],
];
}
}
@@ -23,6 +23,9 @@ public function rules(): array
'entity_types.*' => ['string', 'in:client,client_case,contract,person'], 'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
'allow_attachments' => ['sometimes', 'boolean'], 'allow_attachments' => ['sometimes', 'boolean'],
'active' => ['boolean'], 'active' => ['boolean'],
'client' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
]; ];
} }
} }
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreInstallmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'numeric', 'min:0.01'],
'currency' => ['nullable', 'string', 'size:3'],
'reference' => ['nullable', 'string', 'max:100'],
'installment_at' => ['nullable', 'date'],
'meta' => ['nullable', 'array'],
];
}
}
@@ -26,6 +26,9 @@ public function rules(): array
'reply_to_name' => ['nullable', 'string', 'max:190'], 'reply_to_name' => ['nullable', 'string', 'max:190'],
'priority' => ['nullable', 'integer', 'between:0,65535'], 'priority' => ['nullable', 'integer', 'between:0,65535'],
'max_daily_quota' => ['nullable', 'integer', 'min:0'], 'max_daily_quota' => ['nullable', 'integer', 'min:0'],
'signature' => ['nullable', 'array'],
'signature.*' => ['nullable', 'string', 'max:1000'],
'auto_mailer' => ['nullable', 'boolean'],
]; ];
} }
} }
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateContractSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'create_activity_on_balance_change' => ['sometimes', 'boolean'],
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'activity_note_template' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -25,6 +25,9 @@ public function rules(): array
'entity_types.*' => ['string', 'in:client,client_case,contract,person'], 'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
'allow_attachments' => ['sometimes', 'boolean'], 'allow_attachments' => ['sometimes', 'boolean'],
'active' => ['boolean'], 'active' => ['boolean'],
'client' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
]; ];
} }
} }
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateInstallmentSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'default_currency' => ['required', 'string', 'size:3'],
'create_activity_on_installment' => ['sometimes', 'boolean'],
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
'activity_note_template' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -27,6 +27,9 @@ public function rules(): array
'priority' => ['nullable', 'integer', 'between:0,65535'], 'priority' => ['nullable', 'integer', 'between:0,65535'],
'max_daily_quota' => ['nullable', 'integer', 'min:0'], 'max_daily_quota' => ['nullable', 'integer', 'min:0'],
'active' => ['nullable', 'boolean'], 'active' => ['nullable', 'boolean'],
'signature' => ['nullable', 'array'],
'signature.*' => ['nullable', 'string', 'max:1000'],
'auto_mailer' => ['nullable', 'boolean'],
]; ];
} }
} }
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\RedirectResponse;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
class LoginResponse implements LoginResponseContract
{
public function toResponse($request): RedirectResponse
{
$user = $request->user();
$default = $user?->login_redirect ?: config('fortify.home');
return redirect()->intended($default);
}
}
+296
View File
@@ -0,0 +1,296 @@
<?php
namespace App\Jobs;
use App\Models\Contract;
use App\Models\Email;
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Models\EmailTemplate;
use App\Models\MailProfile;
use App\Models\Package;
use App\Models\PackageItem;
use App\Services\EmailSender;
use App\Services\EmailTemplateRenderer;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class PackageItemEmailJob implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $packageItemId)
{
$this->onQueue('email');
}
public function handle(EmailTemplateRenderer $renderer, EmailSender $sender): void
{
/** @var PackageItem|null $item */
$item = PackageItem::query()->find($this->packageItemId);
if (! $item) {
return;
}
/** @var Package $package */
$package = $item->package;
if (! $package || $package->status === Package::STATUS_CANCELED) {
return;
}
if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) {
return;
}
if ($item->status === 'queued') {
$item->status = 'processing';
$item->save();
$package->increment('processing_count');
}
$payload = (array) $item->payload_json;
$target = (array) $item->target_json;
$to = $target['email'] ?? null;
if (! is_string($to) || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
$item->status = 'failed';
$item->last_error = 'Missing or invalid recipient email.';
$item->save();
$this->updatePackageCounters($item, $package);
return;
}
$templateId = $payload['template_id'] ?? null;
$mailProfileId = $payload['mail_profile_id'] ?? null;
$variables = (array) ($payload['variables'] ?? []);
$subjectOverride = isset($payload['subject']) ? trim((string) $payload['subject']) : null;
if ($subjectOverride === '') {
$subjectOverride = null;
}
$bodyText = isset($payload['body_text']) ? (string) $payload['body_text'] : '';
// Enrich variables with contract/account context when available
$contract = null;
if (! empty($target['contract_id'])) {
$contract = Contract::query()->with(['clientCase.person', 'account.type'])->find($target['contract_id']);
if ($contract) {
$variables['contract'] = [
'id' => $contract->id,
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'start_date' => (string) ($contract->start_date ?? ''),
'end_date' => (string) ($contract->end_date ?? ''),
];
if (is_array($contract->meta) && ! empty($contract->meta)) {
$variables['contract']['meta'] = $this->flattenMeta($contract->meta);
}
if ($contract->account) {
$initialRaw = (string) $contract->account->initial_amount;
$balanceRaw = (string) $contract->account->balance_amount;
$variables['account'] = [
'id' => $contract->account->id,
'reference' => $contract->account->reference,
'initial_amount' => $this->formatAmountEu($initialRaw),
'balance_amount' => $this->formatAmountEu($balanceRaw),
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
'type' => $contract->account->type?->name,
];
}
if ($contract->clientCase?->person) {
$person = $contract->clientCase->person;
$variables['person'] = [
'full_name' => $person->full_name,
'first_name' => $person->first_name,
'last_name' => $person->last_name,
];
}
}
}
/** @var EmailTemplate|null $template */
$template = $templateId ? EmailTemplate::with(['action', 'decision'])->find((int) $templateId) : null;
/** @var MailProfile|null $mailProfile */
$mailProfile = $mailProfileId
? MailProfile::find((int) $mailProfileId)
: MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
try {
if (! $template && ! $subjectOverride) {
throw new \RuntimeException('No email template or subject provided.');
}
$rendered = $template
? $renderer->render([
'subject' => $subjectOverride ?? (string) $template->subject_template,
'html' => (string) $template->html_template,
'text' => (string) $template->text_template,
], array_filter([
'contract' => $contract,
'person' => $contract?->clientCase?->person,
'client' => $contract?->clientCase?->client,
'client_case' => $contract?->clientCase,
'mail_profile' => $mailProfile,
'extra' => $variables,
'body_text' => $bodyText !== '' ? $bodyText : null,
]))
: [
'subject' => $subjectOverride ?? '',
'html' => null,
'text' => null,
];
$log = new EmailLog;
$log->fill([
'uuid' => (string) Str::uuid(),
'template_id' => $template?->id,
'mail_profile_id' => $mailProfile?->id,
'to_email' => $to,
'to_recipients' => [$to],
'subject' => $rendered['subject'],
'body_html_hash' => isset($rendered['html']) ? hash('sha256', (string) $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(),
'contract_id' => $contract?->id,
'client_id' => $contract?->clientCase?->client?->id,
'client_case_id' => $contract?->clientCase?->id,
'extra_context' => ['package_id' => $item->package_id, 'package_item_id' => $item->id],
]);
$log->save();
$log->body()->create([
'body_html' => (string) ($rendered['html'] ?? ''),
'body_text' => (string) ($rendered['text'] ?? ''),
'inline_css' => true,
]);
// Send directly (synchronous within job context)
$start = microtime(true);
$log->status = EmailLogStatus::Sending;
$log->started_at = now();
$log->attempt = 1;
$log->save();
$sender->sendFromLog($log);
$log->status = EmailLogStatus::Sent;
$log->sent_at = now();
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
$log->save();
$item->status = 'sent';
$item->result_json = ['email_log_id' => $log->id, 'subject' => $rendered['subject']];
$item->last_error = null;
$item->save();
// Clear failed flag on successful delivery
Email::query()->where('value', $to)->where('failed', true)->update(['failed' => false]);
// Create activity if the template has action/decision configured
if ($template && ($template->action_id || $template->decision_id) && $contract && $contract->client_case_id) {
$activity = \App\Models\Activity::create(array_filter([
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => 'Poslano: '.$to.', Uspešno'.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
]));
$activity->emailLogs()->attach($log->id);
}
} catch (\Throwable $e) {
$item->status = 'failed';
$item->last_error = $e->getMessage();
$item->save();
// Create activity for failed send if the template has action/decision configured
if ($template && ($template->action_id || $template->decision_id) && isset($contract) && $contract && $contract->client_case_id) {
$shortError = mb_strimwidth($e->getMessage(), 0, 120, '…');
$activity = \App\Models\Activity::create(array_filter([
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => 'Poslano: '.$to.', Napaka pri pošiljanju: '.$shortError.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
]));
if (isset($log) && $log->exists) {
$activity->emailLogs()->attach($log->id);
}
}
// Mark the email address as failed in the DB.
if (isset($to)) {
Email::query()
->where('value', $to)
->update(['failed' => true]);
}
// Permanent SMTP rejection (550 user unknown, 551 not local, 553 invalid address)
// means the address definitively does not exist — also mark it invalid.
if ($e instanceof \Symfony\Component\Mailer\Exception\TransportExceptionInterface
&& preg_match('/\b55[013]\b/', $e->getMessage())
&& isset($to)) {
Email::query()
->where('value', $to)
->update(['valid' => false]);
}
}
$this->updatePackageCounters($item, $package);
}
private function updatePackageCounters(PackageItem $item, Package $package): void
{
if ($item->status === 'sent') {
$package->increment('sent_count');
} else {
$package->increment('failed_count');
}
$package->decrement('processing_count');
$package->refresh();
$done = $package->sent_count + $package->failed_count;
if ($done >= $package->total_items) {
$package->status = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
$package->finished_at = now();
$package->save();
}
}
private function flattenMeta(array $meta, string $prefix = ''): array
{
$result = [];
foreach ($meta as $key => $value) {
$newKey = $prefix === '' ? $key : "{$prefix}.{$key}";
if (is_array($value)) {
if (isset($value['value'])) {
$result[$newKey] = $value['value'];
} else {
$nested = $this->flattenMeta($value, $newKey);
$result = array_merge($result, $nested);
}
} else {
$result[$newKey] = $value;
}
}
return $result;
}
private function formatAmountEu(string $raw): string
{
$numeric = preg_replace('/[^0-9.]/', '', $raw);
$float = (float) $numeric;
return number_format($float, 2, ',', '.');
}
}
+18
View File
@@ -4,6 +4,7 @@
use App\Models\Activity; use App\Models\Activity;
use App\Models\Event as DecisionEventModel; use App\Models\Event as DecisionEventModel;
use App\Services\DecisionEvents\ConditionEvaluator;
use App\Services\DecisionEvents\DecisionEventContext; use App\Services\DecisionEvents\DecisionEventContext;
use App\Services\DecisionEvents\Registry; use App\Services\DecisionEvents\Registry;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@@ -68,6 +69,23 @@ public function handle(): void
user: $activity->user, user: $activity->user,
); );
// [2] Condition check — skip the event if any condition is not met
$conditions = $this->config['conditions'] ?? [];
if (! empty($conditions)) {
$conditionsMet = app(ConditionEvaluator::class)->evaluate($conditions, $context);
if (! $conditionsMet) {
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
'status' => 'skipped',
'message' => 'Condition not met',
'finished_at' => now(),
'updated_at' => now(),
]);
return;
}
}
// [3] Resolve handler → handle()
$handler->handle($context, $this->config); $handler->handle($context, $this->config);
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([ DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
+5
View File
@@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\Email;
use App\Models\EmailLog; use App\Models\EmailLog;
use App\Models\EmailLogStatus; use App\Models\EmailLogStatus;
use App\Services\EmailSender; use App\Services\EmailSender;
@@ -53,6 +54,10 @@ public function handle(): void
$log->duration_ms = (int) round((microtime(true) - $start) * 1000); $log->duration_ms = (int) round((microtime(true) - $start) * 1000);
$log->save(); $log->save();
if ($log->to_email) {
Email::query()->where('value', $log->to_email)->update(['failed' => true]);
}
throw $e; throw $e;
} }
} }
+7 -1
View File
@@ -10,9 +10,10 @@
class Account extends Model class Account extends Model
{ {
use HasFactory;
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */ /** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes; use SoftDeletes;
use HasFactory;
protected $fillable = [ protected $fillable = [
'reference', 'reference',
@@ -58,6 +59,11 @@ public function payments(): HasMany
return $this->hasMany(\App\Models\Payment::class); return $this->hasMany(\App\Models\Payment::class);
} }
public function installments(): HasMany
{
return $this->hasMany(\App\Models\Installment::class);
}
public function bookings(): HasMany public function bookings(): HasMany
{ {
return $this->hasMany(\App\Models\Booking::class); return $this->hasMany(\App\Models\Booking::class);
+19
View File
@@ -7,6 +7,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
class Activity extends Model class Activity extends Model
@@ -18,6 +19,7 @@ class Activity extends Model
protected $fillable = [ protected $fillable = [
'due_date', 'due_date',
'call_back_at',
'amount', 'amount',
'note', 'note',
'action_id', 'action_id',
@@ -27,6 +29,13 @@ class Activity extends Model
'client_case_id', 'client_case_id',
]; ];
/*protected function casts(): array
{
return [
'call_back_at' => 'datetime',
];
}*/
protected $hidden = [ protected $hidden = [
'action_id', 'action_id',
'decision_id', 'decision_id',
@@ -146,4 +155,14 @@ public function user(): BelongsTo
{ {
return $this->belongsTo(\App\Models\User::class); return $this->belongsTo(\App\Models\User::class);
} }
public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(\App\Models\CallLater::class);
}
public function emailLogs(): BelongsToMany
{
return $this->belongsToMany(EmailLog::class, 'activity_email_logs');
}
} }
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CallLater extends Model
{
protected $fillable = [
'activity_id',
'client_case_id',
'contract_id',
'user_id',
'call_back_at',
'completed_at',
];
protected function casts(): array
{
return [
'call_back_at' => 'datetime',
'completed_at' => 'datetime',
];
}
public function activity(): BelongsTo
{
return $this->belongsTo(Activity::class);
}
public function clientCase(): BelongsTo
{
return $this->belongsTo(ClientCase::class);
}
public function contract(): BelongsTo
{
return $this->belongsTo(Contract::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ContractSetting extends Model
{
protected $fillable = [
'create_activity_on_balance_change',
'default_action_id',
'default_decision_id',
'activity_note_template',
];
}
+2
View File
@@ -18,6 +18,7 @@ class Email extends Model
'is_primary', 'is_primary',
'is_active', 'is_active',
'valid', 'valid',
'failed',
'receive_auto_mails', 'receive_auto_mails',
'verified_at', 'verified_at',
'preferences', 'preferences',
@@ -28,6 +29,7 @@ class Email extends Model
'is_primary' => 'boolean', 'is_primary' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
'valid' => 'boolean', 'valid' => 'boolean',
'failed' => 'boolean',
'receive_auto_mails' => 'boolean', 'receive_auto_mails' => 'boolean',
'verified_at' => 'datetime', 'verified_at' => 'datetime',
'preferences' => 'array', 'preferences' => 'array',
+6
View File
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
enum EmailLogStatus: string enum EmailLogStatus: string
@@ -83,4 +84,9 @@ public function body(): HasOne
{ {
return $this->hasOne(EmailLogBody::class, 'email_log_id'); return $this->hasOne(EmailLogBody::class, 'email_log_id');
} }
public function activities(): BelongsToMany
{
return $this->belongsToMany(Activity::class, 'activity_email_logs');
}
} }
+15
View File
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
class EmailTemplate extends Model class EmailTemplate extends Model
@@ -19,10 +20,14 @@ class EmailTemplate extends Model
'entity_types', 'entity_types',
'allow_attachments', 'allow_attachments',
'active', 'active',
'action_id',
'decision_id',
'client',
]; ];
protected $casts = [ protected $casts = [
'active' => 'boolean', 'active' => 'boolean',
'client' => 'boolean',
'entity_types' => 'array', 'entity_types' => 'array',
'allow_attachments' => 'boolean', 'allow_attachments' => 'boolean',
]; ];
@@ -31,4 +36,14 @@ public function documents(): MorphMany
{ {
return $this->morphMany(Document::class, 'documentable'); return $this->morphMany(Document::class, 'documentable');
} }
public function action(): BelongsTo
{
return $this->belongsTo(Action::class);
}
public function decision(): BelongsTo
{
return $this->belongsTo(Decision::class);
}
} }
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Installment extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = [
'account_id',
'amount',
'balance_before',
'currency',
'reference',
'installment_at',
'meta',
'created_by',
'activity_id',
];
protected function casts(): array
{
return [
'installment_at' => 'datetime',
'meta' => 'array',
'amount' => 'decimal:4',
'balance_before' => 'decimal:4',
];
}
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
public function activity(): BelongsTo
{
return $this->belongsTo(Activity::class);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class InstallmentSetting extends Model
{
use HasFactory;
protected $fillable = [
'default_currency',
'create_activity_on_installment',
'default_decision_id',
'default_action_id',
'activity_note_template',
];
}
+4 -2
View File
@@ -10,13 +10,15 @@ class MailProfile extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'name', 'active', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name', 'name', 'active', 'auto_mailer', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
'reply_to_address', 'reply_to_name', 'priority', 'max_daily_quota', 'emails_sent_today', 'reply_to_address', 'reply_to_name', 'priority', 'signature', 'max_daily_quota', 'emails_sent_today',
'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at', 'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at',
]; ];
protected $casts = [ protected $casts = [
'active' => 'boolean', 'active' => 'boolean',
'auto_mailer' => 'boolean',
'signature' => 'array',
'last_success_at' => 'datetime', 'last_success_at' => 'datetime',
'last_error_at' => 'datetime', 'last_error_at' => 'datetime',
'test_checked_at' => 'datetime', 'test_checked_at' => 'datetime',
+2
View File
@@ -34,6 +34,8 @@ public function items()
public const TYPE_SMS = 'sms'; public const TYPE_SMS = 'sms';
public const TYPE_EMAIL = 'email';
public const STATUS_DRAFT = 'draft'; public const STATUS_DRAFT = 'draft';
public const STATUS_QUEUED = 'queued'; public const STATUS_QUEUED = 'queued';
+1
View File
@@ -31,6 +31,7 @@ class User extends Authenticatable
'email', 'email',
'password', 'password',
'active', 'active',
'login_redirect',
]; ];
/** /**
+3 -1
View File
@@ -6,6 +6,7 @@
use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword; use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation; use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Http\Responses\LoginResponse;
use App\Models\User; use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -14,6 +15,7 @@
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Fortify; use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider class FortifyServiceProvider extends ServiceProvider
@@ -23,7 +25,7 @@ class FortifyServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// $this->app->singleton(LoginResponseContract::class, LoginResponse::class);
} }
/** /**
+41 -3
View File
@@ -59,10 +59,23 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
// Resolve eligible recipients: client's person emails with receive_auto_mails = true // Resolve eligible recipients: client's person emails with receive_auto_mails = true
$recipients = []; $recipients = [];
if ($client && $client->person) { if ($client && $client->person) {
$recipients = Email::query() $emails = Email::query()
->where('person_id', $client->person->id) ->where('person_id', $client->person->id)
->where('is_active', true) ->where('is_active', true)
->where('receive_auto_mails', true) ->where('receive_auto_mails', true)
->get(['value', 'preferences']);
$recipients = $emails
->filter(function (Email $email) use ($decision): bool {
$decisionIds = $email->preferences['decision_ids'] ?? [];
// Empty list means "all decisions" — always receive
if (empty($decisionIds)) {
return true;
}
return in_array((int) $decision->id, array_map('intval', $decisionIds), true);
})
->pluck('value') ->pluck('value')
->map(fn ($v) => strtolower(trim((string) $v))) ->map(fn ($v) => strtolower(trim((string) $v)))
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL)) ->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
@@ -77,7 +90,30 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
// Ensure related names are available without extra queries // Ensure related names are available without extra queries
$activity->loadMissing(['action', 'decision']); $activity->loadMissing(['action', 'decision']);
// Ensure account is available on contract (needed for contract.account.* tokens)
if ($contract && ! $contract->relationLoaded('account')) {
$contract->load('account');
}
// Resolve the sending profile once — used both for signature tokens and as the actual sender.
// Prefer the profile explicitly requested via options, fall back to highest-priority active one.
$mailProfile = isset($options['mail_profile_id'])
? MailProfile::query()->find($options['mail_profile_id'])
: null;
$mailProfile ??= MailProfile::query()
->where('active', true)
->where('auto_mailer', true)
->orderBy('priority')
->orderBy('id')
->first();
$mailProfile ??= MailProfile::query()
->where('active', true)
->orderBy('priority')
->orderBy('id')
->first();
// Render content // Render content
$bodyText = isset($options['body_text']) ? (string) $options['body_text'] : '';
$rendered = $this->renderer->render([ $rendered = $this->renderer->render([
'subject' => (string) $template->subject_template, 'subject' => (string) $template->subject_template,
'html' => (string) $template->html_template, 'html' => (string) $template->html_template,
@@ -89,6 +125,8 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
'person' => $person, 'person' => $person,
'activity' => $activity, 'activity' => $activity,
'extra' => [], 'extra' => [],
'mail_profile' => $mailProfile,
'body_text' => $bodyText,
]); ]);
// Create the log and body // Create the log and body
@@ -96,7 +134,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
$log->fill([ $log->fill([
'uuid' => (string) \Str::uuid(), 'uuid' => (string) \Str::uuid(),
'template_id' => $template->id, 'template_id' => $template->id,
'mail_profile_id' => optional(MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first())->id, 'mail_profile_id' => $mailProfile?->id,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'to_email' => (string) ($recipients[0] ?? ''), 'to_email' => (string) ($recipients[0] ?? ''),
'to_recipients' => $recipients, 'to_recipients' => $recipients,
@@ -136,7 +174,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
$log->body()->create([ $log->body()->create([
'body_html' => (string) ($rendered['html'] ?? ''), 'body_html' => (string) ($rendered['html'] ?? ''),
'body_text' => (string) ($rendered['text'] ?? ''), 'body_text' => $bodyText !== '' ? $bodyText : (string) ($rendered['text'] ?? ''),
'inline_css' => true, 'inline_css' => true,
]); ]);
+4 -6
View File
@@ -11,9 +11,9 @@
class ClientCaseDataService class ClientCaseDataService
{ {
/** /**
* Get paginated contracts for a client case with optional segment filtering. * Get contracts for a client case with optional segment filtering.
*/ */
public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int $perPage = 50): LengthAwarePaginator public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Collection
{ {
$query = $clientCase->contracts() $query = $clientCase->contracts()
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at']) ->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
@@ -40,9 +40,7 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int
$query->forSegment($segmentId); $query->forSegment($segmentId);
} }
$perPage = max(1, min(100, $perPage)); return $query->get();
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
} }
/** /**
@@ -56,7 +54,7 @@ public function getActivities(
int $perPage = 20 int $perPage = 20
): LengthAwarePaginator { ): LengthAwarePaginator {
$query = $clientCase->activities() $query = $clientCase->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name', 'emailLogs:id'])
->orderByDesc('created_at'); ->orderByDesc('created_at');
if (! empty($segmentId)) { if (! empty($segmentId)) {
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Services\Contact;
use App\Models\Email;
use App\Models\Person\Person;
class EmailSelector
{
/**
* Select the best email for a person following priority rules.
* Priority:
* 1) verified primary email that is active
* 2) primary email that is active
* 3) any active and valid email
* 4) first active email
*
* Returns an array shape: ['email' => ?Email, 'reason' => ?string]
*/
public function selectForPerson(Person $person): array
{
$emails = Email::query()
->where('person_id', $person->id)
->where('is_active', true)
->orderBy('is_primary', 'desc')
->orderBy('id')
->get();
if ($emails->isEmpty()) {
return ['email' => null, 'reason' => 'no_active_emails'];
}
// 1) verified primary
$email = $emails->first(fn (Email $e) => $e->is_primary && $e->verified_at !== null);
if ($email) {
return ['email' => $email, 'reason' => null];
}
// 2) primary (any verification)
$email = $emails->first(fn (Email $e) => $e->is_primary);
if ($email) {
return ['email' => $email, 'reason' => null];
}
// 3) valid (any)
$email = $emails->first(fn (Email $e) => $e->valid);
if ($email) {
return ['email' => $email, 'reason' => null];
}
// 4) first active
return ['email' => $emails->first(), 'reason' => null];
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Services\DecisionEvents;
class ConditionEvaluator
{
/**
* Returns true when ALL conditions pass (AND logic).
*
* Each condition: { field: string, operator: string, value: mixed }
*
* @param array<int, array{field: string, operator: string, value: mixed}> $conditions
*/
public function evaluate(array $conditions, DecisionEventContext $context): bool
{
foreach ($conditions as $condition) {
if (! $this->evaluateOne($condition, $context)) {
return false;
}
}
return true;
}
protected function evaluateOne(array $condition, DecisionEventContext $context): bool
{
$field = $condition['field'] ?? '';
$operator = $condition['operator'] ?? '=';
$expected = $condition['value'] ?? null;
$actual = $this->resolveField($field, $context);
return $this->compare($actual, $operator, $expected);
}
protected function resolveField(string $field, DecisionEventContext $context): mixed
{
return match ($field) {
'activity.amount' => $context->activity?->amount,
'activity.note' => $context->activity?->note,
'contract.active' => $context->contract !== null ? (bool) $context->contract->active : null,
'contract.account.balance_amount' => $this->resolveAccountBalance($context),
default => null,
};
}
private function resolveAccountBalance(DecisionEventContext $context): mixed
{
if (! $context->contract) {
return null;
}
$context->contract->loadMissing('account');
return $context->contract->account?->balance_amount;
}
protected function compare(mixed $actual, string $operator, mixed $expected): bool
{
if ($actual === null) {
return false;
}
if (in_array($operator, ['>', '>=', '<', '<='], true)) {
$actual = (float) $actual;
$expected = (float) $expected;
}
return match ($operator) {
'=' => $actual == $expected,
'!=' => $actual != $expected,
'>' => $actual > $expected,
'>=' => $actual >= $expected,
'<' => $actual < $expected,
'<=' => $actual <= $expected,
'contains' => str_contains((string) $actual, (string) $expected),
default => false,
};
}
/**
* Returns available condition field definitions for the frontend.
*
* @return array<int, array{key: string, label: string, type: string}>
*/
public static function availableFields(): array
{
return [
['key' => 'activity.amount', 'label' => 'Aktivnost znesek', 'type' => 'numeric'],
['key' => 'activity.note', 'label' => 'Aktivnost opomba', 'type' => 'string'],
['key' => 'contract.active', 'label' => 'Pogodba aktivna', 'type' => 'boolean'],
['key' => 'contract.account.balance_amount', 'label' => 'Račun stanje', 'type' => 'numeric'],
];
}
/**
* Returns available operators grouped by field type.
*
* @return array<string, array<int, array{key: string, label: string}>>
*/
public static function availableOperators(): array
{
return [
'numeric' => [
['key' => '=', 'label' => 'je enako'],
['key' => '!=', 'label' => 'ni enako'],
['key' => '>', 'label' => 'je večje od'],
['key' => '>=', 'label' => 'je večje ali enako'],
['key' => '<', 'label' => 'je manjše od'],
['key' => '<=', 'label' => 'je manjše ali enako'],
],
'string' => [
['key' => '=', 'label' => 'je enako'],
['key' => '!=', 'label' => 'ni enako'],
['key' => 'contains', 'label' => 'vsebuje'],
],
'boolean' => [
['key' => '=', 'label' => 'je'],
['key' => '!=', 'label' => 'ni'],
],
];
}
}
@@ -36,6 +36,14 @@ public function handle(DecisionEventContext $context, array $config = []): void
$setting->reactivate = (bool) $config['reactivate']; $setting->reactivate = (bool) $config['reactivate'];
} }
// Cancel all active FieldJobs for this contract before archiving (raw update to avoid boot-event side effects)
\DB::table('field_jobs')
->where('contract_id', $contractId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->whereNull('deleted_at')
->update(['cancelled_at' => now(), 'updated_at' => now()]);
$results = app(ArchiveExecutor::class)->executeSetting( $results = app(ArchiveExecutor::class)->executeSetting(
$setting, $setting,
['contract_id' => $contractId], ['contract_id' => $contractId],
@@ -0,0 +1,27 @@
<?php
namespace App\Services\DecisionEvents\Handlers;
use App\Models\CallLater;
use App\Services\DecisionEvents\Contracts\DecisionEventHandler;
use App\Services\DecisionEvents\DecisionEventContext;
class CallLaterHandler implements DecisionEventHandler
{
public function handle(DecisionEventContext $context, array $config = []): void
{
$activity = $context->activity;
if (empty($activity->call_back_at)) {
return;
}
CallLater::create([
'activity_id' => $activity->id,
'client_case_id' => $activity->client_case_id,
'contract_id' => $activity->contract_id,
'user_id' => $activity->user_id,
'call_back_at' => $activity->call_back_at,
]);
}
}
+5 -1
View File
@@ -17,15 +17,19 @@ class Registry
'add_segment' => AddSegmentHandler::class, 'add_segment' => AddSegmentHandler::class,
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class, 'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class,
'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class, 'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class,
'add_call_later' => \App\Services\DecisionEvents\Handlers\CallLaterHandler::class,
]; ];
public static function resolve(string $key): DecisionEventHandler public static function resolve(string $key): DecisionEventHandler
{ {
$key = trim(strtolower($key)); $key = trim(strtolower($key));
$class = static::$map[$key] ?? null; $class = static::$map[$key] ?? null;
if (! $class || ! class_exists($class)) { if (! $class) {
throw new InvalidArgumentException("Unknown decision event handler for key: {$key}"); throw new InvalidArgumentException("Unknown decision event handler for key: {$key}");
} }
if (! class_exists($class)) {
throw new InvalidArgumentException("Handler class {$class} for key {$key} does not exist (check autoload)");
}
$handler = app($class); $handler = app($class);
if (! $handler instanceof DecisionEventHandler) { if (! $handler instanceof DecisionEventHandler) {
throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler"); throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler");
-35
View File
@@ -152,19 +152,6 @@ public function sendFromLog(EmailLog $log): array
$email->to(new Address($singleTo, (string) ($log->to_name ?? ''))); $email->to(new Address($singleTo, (string) ($log->to_name ?? '')));
} }
// Always BCC the sender mailbox if present and not already in To
$senderBcc = null;
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
// Check duplicates against toList
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
$senderBcc = $fromAddr;
$email->bcc(new Address($senderBcc));
// Persist BCC for auditing
$log->bcc = [$senderBcc];
}
}
if (! empty($text)) { if (! empty($text)) {
$email->text($text); $email->text($text);
} }
@@ -304,10 +291,6 @@ public function sendFromLog(EmailLog $log): array
} }
$mailer->send($email); $mailer->send($email);
// Save log if we modified BCC
if (! empty($log->getAttribute('bcc'))) {
$log->save();
}
$headers = $email->getHeaders(); $headers = $email->getHeaders();
$messageIdHeader = $headers->get('Message-ID'); $messageIdHeader = $headers->get('Message-ID');
$messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null; $messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null;
@@ -330,15 +313,6 @@ public function sendFromLog(EmailLog $log): array
$message->to($singleTo); $message->to($singleTo);
} }
} }
// BCC the sender mailbox if resolvable and not already in To
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
$message->bcc($fromAddr);
$log->bcc = [$fromAddr];
}
}
$message->subject($subject); $message->subject($subject);
if (! empty($log->reply_to)) { if (! empty($log->reply_to)) {
$message->replyTo($log->reply_to); $message->replyTo($log->reply_to);
@@ -464,15 +438,6 @@ public function sendFromLog(EmailLog $log): array
$message->to($singleTo); $message->to($singleTo);
} }
} }
// BCC the sender mailbox if resolvable and not already in To
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
$message->bcc($fromAddr);
$log->bcc = [$fromAddr];
}
}
$message->subject($subject); $message->subject($subject);
if (! empty($log->reply_to)) { if (! empty($log->reply_to)) {
$message->replyTo($log->reply_to); $message->replyTo($log->reply_to);
+87 -6
View File
@@ -30,17 +30,49 @@ public function render(array $template, array $ctx): array
return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) { return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) {
$key = $m[1]; $key = $m[1];
return (string) data_get($map, $key, ''); // body_text is handled separately by applyBodyText(); preserve as literal
if ($key === 'body_text') {
return $m[0];
}
$value = data_get($map, $key, '');
// If the resolved value is an array (e.g. {{ contract.meta }} used directly),
// return empty string instead of triggering "Array to string conversion".
if (is_array($value)) {
return '';
}
return (string) $value;
}, $input); }, $input);
}; };
$bodyText = isset($ctx['body_text']) ? (string) $ctx['body_text'] : '';
return [ return [
'subject' => $replacer($template['subject']) ?? '', 'subject' => $replacer($template['subject']) ?? '',
'html' => $replacer($template['html'] ?? null) ?? null, 'html' => $this->applyBodyText($replacer($template['html'] ?? null) ?? null, $bodyText, html: true),
'text' => $replacer($template['text'] ?? null) ?? null, 'text' => $this->applyBodyText($replacer($template['text'] ?? null) ?? null, $bodyText, html: false),
]; ];
} }
/**
* Substitute the literal {{body_text}} placeholder with the user-supplied body text.
* In HTML context the text is HTML-escaped and newlines are converted to <br>.
* In plain-text context the raw value is used.
*/
public function applyBodyText(?string $content, string $bodyText, bool $html = true): ?string
{
if ($content === null) {
return null;
}
$replacement = $html
? nl2br(htmlspecialchars($bodyText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'))
: $bodyText;
return preg_replace('/{{\s*body_text\s*}}/', $replacement, $content);
}
/** /**
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx * @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
*/ */
@@ -145,12 +177,18 @@ 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'),
// Format amounts in EU style for emails // Account amounts — sourced from the related Account model
'amount' => $formatMoneyEu(data_get($co, 'amount')), 'account' => [
'balance_amount' => $formatMoneyEu(data_get($co, 'account.balance_amount')),
'initial_amount' => $formatMoneyEu(data_get($co, 'account.initial_amount')),
],
]; ];
$meta = data_get($co, 'meta'); $meta = data_get($co, 'meta');
if (is_string($meta)) {
$meta = json_decode($meta, true) ?? [];
}
if (is_array($meta)) { if (is_array($meta)) {
$out['contract']['meta'] = $meta; $out['contract']['meta'] = $this->flattenMetaForTemplate($meta);
} }
} }
if (isset($ctx['activity'])) { if (isset($ctx['activity'])) {
@@ -172,7 +210,50 @@ protected function buildMap(array $ctx): array
if (! empty($ctx['extra']) && is_array($ctx['extra'])) { if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
$out['extra'] = $ctx['extra']; $out['extra'] = $ctx['extra'];
} }
if (isset($ctx['mail_profile'])) {
$mp = $ctx['mail_profile'];
$out['profile'] = [
'signature' => is_array($mp->signature) ? $mp->signature : [],
];
}
return $out; return $out;
} }
/**
* Flatten a contract meta array so every leaf value is accessible by its bare key.
*
* Handles three formats stored in the wild:
* 1. Numeric wrapper: { "1": { "sklic": "SI00…", "job_days": 1 } }
* { "sklic": "SI00…", "job_days": 1 }
* 2. Structured entry: { "sklic": { "value": "SI00…", "type": "string" } }
* { "sklic": "SI00…" }
* 3. Already flat: { "sklic": "SI00…" }
* { "sklic": "SI00…" }
*/
private function flattenMetaForTemplate(array $meta): array
{
$flat = [];
foreach ($meta as $key => $item) {
if (!is_array($item)) {
// Plain scalar — keep as-is (format 3)
if (!array_key_exists($key, $flat)) {
$flat[$key] = $item;
}
} elseif (array_key_exists('value', $item)) {
// Structured { value, type, title } entry (format 2)
$flat[$key] = $item['value'];
} elseif (is_numeric($key)) {
// Numeric wrapper key — recurse and alias without the prefix (format 1)
foreach ($this->flattenMetaForTemplate($item) as $nk => $nv) {
if (!array_key_exists($nk, $flat)) {
$flat[$nk] = $nv;
}
}
}
// Non-numeric nested arrays without a 'value' key are silently skipped
}
return $flat;
}
} }
+4 -4
View File
@@ -10,21 +10,21 @@
"barryvdh/laravel-dompdf": "^3.1", "barryvdh/laravel-dompdf": "^3.1",
"diglactic/laravel-breadcrumbs": "^10.0", "diglactic/laravel-breadcrumbs": "^10.0",
"http-interop/http-factory-guzzle": "^1.2", "http-interop/http-factory-guzzle": "^1.2",
"inertiajs/inertia-laravel": "^2.0", "inertiajs/inertia-laravel": "^3.0",
"laravel/framework": "12.0", "laravel/framework": "^12.0",
"laravel/jetstream": "^5.2", "laravel/jetstream": "^5.2",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/scout": "^10.11", "laravel/scout": "^10.11",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
"meilisearch/meilisearch-php": "^1.11", "meilisearch/meilisearch-php": "^1.11",
"robertboes/inertia-breadcrumbs": "dev-laravel-12", "robertboes/inertia-breadcrumbs": "^1.0",
"tightenco/ziggy": "^2.0", "tightenco/ziggy": "^2.0",
"tijsverkoyen/css-to-inline-styles": "^2.2" "tijsverkoyen/css-to-inline-styles": "^2.2"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/boost": "^1.1", "laravel/boost": "^2.2",
"laravel/pint": "^1.13", "laravel/pint": "^1.13",
"laravel/sail": "^1.26", "laravel/sail": "^1.26",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
Generated
+1031 -746
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
<?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::table('activities', function (Blueprint $table) {
$table->dateTime('call_back_at')->nullable()->after('due_date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('activities', function (Blueprint $table) {
$table->dropColumn('call_back_at');
});
}
};
@@ -0,0 +1,33 @@
<?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('call_laters', function (Blueprint $table) {
$table->id();
$table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete();
$table->foreignId('client_case_id')->constrained('client_cases')->cascadeOnDelete();
$table->foreignId('contract_id')->nullable()->constrained('contracts')->nullOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->dateTime('call_back_at');
$table->dateTime('completed_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('call_laters');
}
};
@@ -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
{
public function up(): void
{
Schema::create('installments', function (Blueprint $table): void {
$table->id();
$table->foreignId('account_id')->constrained('accounts')->cascadeOnDelete();
$table->decimal('amount', 20, 4);
$table->decimal('balance_before', 20, 4)->nullable();
$table->string('currency', 3)->default('EUR');
$table->string('reference', 100)->nullable();
$table->timestamp('installment_at')->nullable();
$table->json('meta')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('activity_id')->nullable()->constrained('activities')->nullOnDelete();
$table->softDeletes();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('installments');
}
};
@@ -0,0 +1,26 @@
<?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::create('installment_settings', function (Blueprint $table): void {
$table->id();
$table->string('default_currency', 3)->default('EUR');
$table->boolean('create_activity_on_installment')->default(false);
$table->foreignId('default_decision_id')->nullable()->constrained('decisions')->nullOnDelete();
$table->foreignId('default_action_id')->nullable()->constrained('actions')->nullOnDelete();
$table->string('activity_note_template', 255)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('installment_settings');
}
};
@@ -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('contract_settings', function (Blueprint $table): void {
$table->id();
$table->boolean('create_activity_on_balance_change')->default(false);
$table->foreignId('default_action_id')->nullable()->constrained('actions')->nullOnDelete();
$table->foreignId('default_decision_id')->nullable()->constrained('decisions')->nullOnDelete();
$table->string('activity_note_template', 255)->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('contract_settings');
}
};
@@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement('ALTER TABLE field_jobs ALTER COLUMN assigned_at TYPE timestamp USING assigned_at::timestamp');
DB::statement('ALTER TABLE field_jobs ALTER COLUMN completed_at TYPE timestamp USING completed_at::timestamp');
DB::statement('ALTER TABLE field_jobs ALTER COLUMN cancelled_at TYPE timestamp USING cancelled_at::timestamp');
}
public function down(): void
{
DB::statement('ALTER TABLE field_jobs ALTER COLUMN assigned_at TYPE date USING assigned_at::date');
DB::statement('ALTER TABLE field_jobs ALTER COLUMN completed_at TYPE date USING completed_at::date');
DB::statement('ALTER TABLE field_jobs ALTER COLUMN cancelled_at TYPE date USING cancelled_at::date');
}
};
@@ -0,0 +1,28 @@
<?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::table('users', function (Blueprint $table) {
$table->string('login_redirect')->nullable()->after('active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('login_redirect');
});
}
};
@@ -0,0 +1,22 @@
<?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('mail_profiles', function (Blueprint $table) {
$table->jsonb('signature')->nullable()->after('priority');
});
}
public function down(): void
{
Schema::table('mail_profiles', function (Blueprint $table) {
$table->dropColumn('signature');
});
}
};
@@ -0,0 +1,25 @@
<?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_templates', function (Blueprint $table): void {
$table->foreignId('action_id')->nullable()->after('active')->constrained('actions')->nullOnDelete();
$table->foreignId('decision_id')->nullable()->after('action_id')->constrained('decisions')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('email_templates', function (Blueprint $table): void {
$table->dropForeign(['action_id']);
$table->dropForeign(['decision_id']);
$table->dropColumn(['action_id', 'decision_id']);
});
}
};
@@ -0,0 +1,22 @@
<?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('mail_profiles', function (Blueprint $table): void {
$table->boolean('auto_mailer')->default(false)->after('active');
});
}
public function down(): void
{
Schema::table('mail_profiles', function (Blueprint $table): void {
$table->dropColumn('auto_mailer');
});
}
};
@@ -0,0 +1,25 @@
<?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::create('activity_email_logs', function (Blueprint $table): void {
$table->id();
$table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete();
$table->foreignId('email_log_id')->constrained('email_logs')->cascadeOnDelete();
$table->timestamps();
$table->unique(['activity_id', 'email_log_id']);
});
}
public function down(): void
{
Schema::dropIfExists('activity_email_logs');
}
};
@@ -0,0 +1,28 @@
<?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::table('emails', function (Blueprint $table) {
$table->boolean('failed')->default(false)->after('valid');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('emails', function (Blueprint $table) {
$table->dropColumn('failed');
});
}
};
@@ -0,0 +1,28 @@
<?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::table('email_templates', function (Blueprint $table): void {
$table->boolean('client')->default(false)->after('active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('email_templates', function (Blueprint $table): void {
$table->dropColumn('client');
});
}
};
+5
View File
@@ -31,6 +31,11 @@ public function run(): void
'name' => 'End field job', 'name' => 'End field job',
'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).', 'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).',
], ],
[
'key' => 'add_call_later',
'name' => 'Klic kasneje',
'description' => 'Ustvari zapis za povratni klic ob določenem datumu in uri.',
],
]; ];
foreach ($rows as $row) { foreach ($rows as $row) {
+262
View File
@@ -21,6 +21,7 @@ public function run(): void
$this->seedSegmentActivityCountsReport(); $this->seedSegmentActivityCountsReport();
$this->seedActionsDecisionsCountReport(); $this->seedActionsDecisionsCountReport();
$this->seedActivitiesPerPeriodReport(); $this->seedActivitiesPerPeriodReport();
$this->seedActivitiesDetailReport();
} }
protected function seedActiveContractsReport(): void protected function seedActiveContractsReport(): void
@@ -783,4 +784,265 @@ protected function seedActivitiesPerPeriodReport(): void
'order' => 0, 'order' => 0,
]); ]);
} }
protected function seedActivitiesDetailReport(): void
{
$report = Report::create([
'slug' => 'activities-detail',
'name' => 'Aktivnosti pregled',
'description' => 'Podroben pregled aktivnosti z možnostjo filtriranja po stranki, datumu, akciji in odločitvi.',
'category' => 'activities',
'enabled' => true,
'order' => 7,
]);
// Entities (joins)
$report->entities()->create([
'model_class' => 'App\\Models\\Activity',
'join_type' => 'base',
'order' => 0,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Action',
'join_type' => 'leftJoin',
'join_first' => 'activities.action_id',
'join_operator' => '=',
'join_second' => 'actions.id',
'order' => 1,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Decision',
'join_type' => 'leftJoin',
'join_first' => 'activities.decision_id',
'join_operator' => '=',
'join_second' => 'decisions.id',
'order' => 2,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Contract',
'join_type' => 'leftJoin',
'join_first' => 'activities.contract_id',
'join_operator' => '=',
'join_second' => 'contracts.id',
'order' => 3,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\ClientCase',
'join_type' => 'leftJoin',
'join_first' => 'activities.client_case_id',
'join_operator' => '=',
'join_second' => 'client_cases.id',
'order' => 4,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Client',
'join_type' => 'leftJoin',
'join_first' => 'client_cases.client_id',
'join_operator' => '=',
'join_second' => 'clients.id',
'order' => 5,
]);
$report->entities()->createMany([
[
'model_class' => 'App\\Models\\Person\\Person',
'alias' => 'client_people',
'join_type' => 'leftJoin',
'join_first' => 'clients.person_id',
'join_operator' => '=',
'join_second' => 'client_people.id',
'order' => 6,
],
[
'model_class' => 'App\\Models\\Person\\Person',
'alias' => 'subject_people',
'join_type' => 'leftJoin',
'join_first' => 'client_cases.person_id',
'join_operator' => '=',
'join_second' => 'subject_people.id',
'order' => 7,
],
]);
// Columns
$report->columns()->createMany([
[
'key' => 'contract_reference',
'label' => 'Pogodba',
'type' => 'string',
'expression' => 'contracts.reference',
'sortable' => true,
'visible' => true,
'order' => 0,
],
[
'key' => 'naziv',
'label' => 'Naziv',
'type' => 'string',
'expression' => 'subject_people.full_name',
'sortable' => true,
'visible' => true,
'order' => 1,
],
[
'key' => 'stranka',
'label' => 'Stranka',
'type' => 'string',
'expression' => 'client_people.full_name',
'sortable' => true,
'visible' => true,
'order' => 2,
],
[
'key' => 'aktivnost',
'label' => 'Aktivnost',
'type' => 'string',
'expression' => "CONCAT(COALESCE(actions.name, ''), ' / ', COALESCE(decisions.name, ''))",
'sortable' => false,
'visible' => true,
'order' => 3,
],
[
'key' => 'datum',
'label' => 'Datum',
'type' => 'date',
'expression' => 'DATE(activities.created_at)',
'sortable' => true,
'visible' => true,
'order' => 4,
],
[
'key' => 'opomba',
'label' => 'Opomba',
'type' => 'string',
'expression' => 'activities.note',
'sortable' => false,
'visible' => true,
'order' => 5,
],
[
'key' => 'zapadlost',
'label' => 'Zapadlost',
'type' => 'date',
'expression' => 'activities.due_date',
'sortable' => true,
'visible' => true,
'order' => 6,
],
[
'key' => 'znesek',
'label' => 'Znesek',
'type' => 'currency',
'expression' => 'activities.amount',
'sortable' => true,
'visible' => true,
'order' => 7,
],
]);
// Filters
$report->filters()->createMany([
[
'key' => 'client_uuid',
'label' => 'Stranka',
'type' => 'select:client',
'nullable' => true,
'order' => 0,
],
[
'key' => 'from',
'label' => 'Datum od',
'type' => 'date',
'nullable' => true,
'order' => 1,
],
[
'key' => 'to',
'label' => 'Datum do',
'type' => 'date',
'nullable' => true,
'order' => 2,
],
[
'key' => 'action_id',
'label' => 'Akcija',
'type' => 'select:action',
'nullable' => true,
'order' => 3,
],
[
'key' => 'decision_id',
'label' => 'Odločitev',
'type' => 'select:decision',
'nullable' => true,
'order' => 4,
],
]);
// Conditions (all filter-based, skipped when null)
$report->conditions()->createMany([
[
'column' => 'activities.created_at',
'operator' => '>=',
'value_type' => 'filter',
'filter_key' => 'from',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.created_at',
'operator' => '<=',
'value_type' => 'filter',
'filter_key' => 'to',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 1,
'enabled' => true,
],
[
'column' => 'clients.uuid',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'client_uuid',
'logical_operator' => 'AND',
'group_id' => 2,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.action_id',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'action_id',
'logical_operator' => 'AND',
'group_id' => 3,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.decision_id',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'decision_id',
'logical_operator' => 'AND',
'group_id' => 4,
'order' => 0,
'enabled' => true,
],
]);
// Order
$report->orders()->create([
'column' => 'activities.created_at',
'direction' => 'DESC',
'order' => 0,
]);
}
} }
+42 -138
View File
@@ -46,7 +46,7 @@
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@inertiajs/vue3": "2.0", "@inertiajs/vue3": "^3.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
@@ -952,26 +952,35 @@
} }
}, },
"node_modules/@inertiajs/core": { "node_modules/@inertiajs/core": {
"version": "2.0.17", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.0.17.tgz", "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.0.3.tgz",
"integrity": "sha512-tvYoqiouQSJrP7i7zVq61yyuEjlL96UU4nkkOWtOajXZlubGN4XrgRpnygpDk1KBO8V2yBab3oUZm+aZImwTHg==", "integrity": "sha512-/4sW/cfNpvujjVOZlB5UNypLGNySs7X7V8IMLNSK8+3j1KsUYGS5wpLd9EqAu8wy8RiW7PPra2rPwB6Lx/ACow==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.8.2", "@jridgewell/trace-mapping": "^0.3.31",
"es-toolkit": "^1.34.1", "es-toolkit": "^1.33.0",
"qs": "^6.9.0" "laravel-precognition": "^2.0.0"
},
"peerDependencies": {
"axios": "^1.13.2"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
} }
}, },
"node_modules/@inertiajs/vue3": { "node_modules/@inertiajs/vue3": {
"version": "2.0.17", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.0.17.tgz", "resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-3.0.3.tgz",
"integrity": "sha512-Al0IMHQSj5aTQBLUAkljFEMCw4YRwSiOSKzN8LAbvJpKwvJFgc/wSj3wVVpr/AO9y9mz1w2mtvjnDoOzsntPLw==", "integrity": "sha512-bhJN+GS66g1tYH1p6flKkG1N8oaT5J7ZLqBkavN9mHC6bVfoQCUG6sCuA07WTDfo9tDaxU89wsSSAf4mhn3SuA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@inertiajs/core": "2.0.17", "@inertiajs/core": "3.0.3",
"es-toolkit": "^1.33.0" "es-toolkit": "^1.33.0",
"laravel-precognition": "^2.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "^3.0.0" "vue": "^3.0.0"
@@ -3804,9 +3813,9 @@
} }
}, },
"node_modules/es-toolkit": { "node_modules/es-toolkit": {
"version": "1.43.0", "version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
@@ -4372,6 +4381,24 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/laravel-precognition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz",
"integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-toolkit": "^1.32.0"
},
"peerDependencies": {
"axios": "^1.4.0"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/laravel-vite-plugin": { "node_modules/laravel-vite-plugin": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
@@ -4875,19 +4902,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": { "node_modules/object-is": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
@@ -5098,22 +5112,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quickselect": { "node_modules/quickselect": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
@@ -5361,82 +5359,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/skema": { "node_modules/skema": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz", "resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz",
@@ -6029,24 +5951,6 @@
"which": "bin/which" "which": "bin/which"
} }
}, },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+5 -3
View File
@@ -7,7 +7,7 @@
"typecheck": "vue-tsc --noEmit -p tsconfig.json" "typecheck": "vue-tsc --noEmit -p tsconfig.json"
}, },
"devDependencies": { "devDependencies": {
"@inertiajs/vue3": "2.0", "@inertiajs/vue3": "^3.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
@@ -34,12 +34,13 @@
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@internationalized/date": "^3.10.0", "@internationalized/date": "^3.10.0",
"@lucide/vue": "^1.21.0",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@unovis/ts": "^1.6.2", "@unovis/ts": "^1.6.2",
"@unovis/vue": "^1.6.2", "@unovis/vue": "^1.6.2",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
"@vuepic/vue-datepicker": "^11.0.3", "@vuepic/vue-datepicker": "^11.0.3",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.3.0",
"apexcharts": "^4.7.0", "apexcharts": "^4.7.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clean": "^4.0.2", "clean": "^4.0.2",
@@ -51,11 +52,12 @@
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"preline": "^2.7.0", "preline": "^2.7.0",
"quill": "^1.3.7", "quill": "^1.3.7",
"reka-ui": "^2.7.0", "reka-ui": "^2.10.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tailwindcss-inner-border": "^0.2.0", "tailwindcss-inner-border": "^0.2.0",
"v-calendar": "^3.1.2", "v-calendar": "^3.1.2",
"vaul-vue": "^0.4.1",
"vee-validate": "^4.15.1", "vee-validate": "^4.15.1",
"vue-currency-input": "^3.2.1", "vue-currency-input": "^3.2.1",
"vue-multiselect": "^3.4.0", "vue-multiselect": "^3.4.0",
+3
View File
@@ -11,6 +11,9 @@
<testsuite name="Feature"> <testsuite name="Feature">
<directory>tests/Feature</directory> <directory>tests/Feature</directory>
</testsuite> </testsuite>
<testsuite name="Pure">
<directory>tests/Pure</directory>
</testsuite>
</testsuites> </testsuites>
<source> <source>
<include> <include>
@@ -11,7 +11,7 @@ import {
} from "@tanstack/vue-table"; } from "@tanstack/vue-table";
import { valueUpdater } from "@/lib/utils"; import { valueUpdater } from "@/lib/utils";
import DataTableColumnHeader from "./DataTableColumnHeader.vue"; import DataTableColumnHeader from "./DataTableColumnHeader.vue";
import DataTablePagination from "./DataTablePagination.vue"; import DataTablePaginationClient from "./DataTablePaginationClient.vue";
import DataTableViewOptions from "./DataTableViewOptions.vue"; import DataTableViewOptions from "./DataTableViewOptions.vue";
import DataTableToolbar from "./DataTableToolbar.vue"; import DataTableToolbar from "./DataTableToolbar.vue";
import SkeletonTable from "../Skeleton/SkeletonTable.vue"; import SkeletonTable from "../Skeleton/SkeletonTable.vue";
@@ -618,7 +618,14 @@ defineExpose({
<!-- Client-side pagination --> <!-- Client-side pagination -->
<template v-else> <template v-else>
<DataTablePagination :table="table" /> <DataTablePaginationClient
:current-page="table.getState().pagination.pageIndex"
:last-page="table.getPageCount()"
:total="table.getFilteredRowModel().rows.length"
:showing-from="table.getFilteredSelectedRowModel().rows.length"
:showing-to="table.getFilteredRowModel().rows.length"
:table="table"
/>
</template> </template>
</div> </div>
</div> </div>
@@ -23,6 +23,7 @@ const props = defineProps({
showGoto: { type: Boolean, default: true }, showGoto: { type: Boolean, default: true },
maxPageLinks: { type: Number, default: 5 }, maxPageLinks: { type: Number, default: 5 },
perPage: { type: Number, default: 10 }, perPage: { type: Number, default: 10 },
table: { type: Object, required: true },
}); });
const emit = defineEmits(["update:page"]); const emit = defineEmits(["update:page"]);
@@ -34,7 +35,7 @@ function goToPageInput() {
const n = Number(raw); const n = Number(raw);
if (!Number.isFinite(n)) return; if (!Number.isFinite(n)) return;
const target = Math.max(1, Math.min(props.lastPage, Math.floor(n))); const target = Math.max(1, Math.min(props.lastPage, Math.floor(n)));
if (target !== props.currentPage) setPage(target); if (target !== props.currentPage) props.table.setPageIndex(target - 1);
gotoInput.value = ""; gotoInput.value = "";
} }
@@ -136,14 +137,17 @@ function setPage(p) {
> >
<PaginationContent> <PaginationContent>
<!-- First --> <!-- First -->
<PaginationFirst :disabled="currentPage <= 1" @click="setPage(1)"> <PaginationFirst
:disabled="!table.getCanPreviousPage()"
@click="table.setPageIndex(0)"
>
<ChevronsLeft /> <ChevronsLeft />
</PaginationFirst> </PaginationFirst>
<!-- Previous --> <!-- Previous -->
<PaginationPrevious <PaginationPrevious
:disabled="currentPage <= 1" :disabled="!table.getCanPreviousPage()"
@click="setPage(currentPage - 1)" @click="table.previousPage()"
> >
<ChevronLeft /> <ChevronLeft />
</PaginationPrevious> </PaginationPrevious>
@@ -154,25 +158,22 @@ function setPage(p) {
<PaginationItem <PaginationItem
v-else v-else
:value="item" :value="item"
:is-active="currentPage === item" :is-active="currentPage === index"
@click="setPage(item)" @click="table.setPageIndex(index)"
> >
{{ item }} {{ item }}
</PaginationItem> </PaginationItem>
</template> </template>
<!-- Next --> <!-- Next -->
<PaginationNext <PaginationNext :disabled="!table.getCanNextPage()" @click="table.nextPage()">
:disabled="currentPage >= lastPage"
@click="setPage(currentPage + 1)"
>
<ChevronRight /> <ChevronRight />
</PaginationNext> </PaginationNext>
<!-- Last --> <!-- Last -->
<PaginationLast <PaginationLast
:disabled="currentPage >= lastPage" :disabled="!table.getCanNextPage()"
@click="setPage(lastPage)" @click="table.setPageIndex(table.getPageCount() - 1)"
> >
<ChevronsRight /> <ChevronsRight />
</PaginationLast> </PaginationLast>
@@ -191,7 +192,7 @@ function setPage(p) {
:max="lastPage" :max="lastPage"
inputmode="numeric" inputmode="numeric"
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
:placeholder="String(currentPage)" :placeholder="String(currentPage + 1)"
aria-label="Pojdi na stran" aria-label="Pojdi na stran"
@keyup.enter="goToPageInput" @keyup.enter="goToPageInput"
@blur="goToPageInput" @blur="goToPageInput"
+5 -8
View File
@@ -2,11 +2,7 @@
import { computed, ref, useAttrs } from "vue"; import { computed, ref, useAttrs } from "vue";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Calendar } from "@/Components/ui/calendar"; import { Calendar } from "@/Components/ui/calendar";
import { import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/Components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CalendarIcon } from "lucide-vue-next"; import { CalendarIcon } from "lucide-vue-next";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -86,7 +82,9 @@ const toCalendarDate = (value) => {
// Convert CalendarDate to ISO string (YYYY-MM-DD) // Convert CalendarDate to ISO string (YYYY-MM-DD)
const fromCalendarDate = (calendarDate) => { const fromCalendarDate = (calendarDate) => {
if (!calendarDate) return null; if (!calendarDate) return null;
return `${String(calendarDate.year).padStart(4, "0")}-${String(calendarDate.month).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`; return `${String(calendarDate.year).padStart(4, "0")}-${String(
calendarDate.month
).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
}; };
const calendarDate = computed({ const calendarDate = computed({
@@ -142,11 +140,10 @@ const open = ref(false);
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start"> <PopoverContent class="w-auto p-0" align="start">
<Calendar v-model="calendarDate" :disabled="disabled" /> <Calendar locale="sl-SI" v-model="calendarDate" :disabled="disabled" />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<p v-if="error" class="mt-1 text-sm text-red-600"> <p v-if="error" class="mt-1 text-sm text-red-600">
{{ Array.isArray(error) ? error[0] : error }} {{ Array.isArray(error) ? error[0] : error }}
</p> </p>
</template> </template>
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, watch } from "vue"; import { ref, computed, watch, onUnmounted } from "vue";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -9,7 +9,7 @@ import {
} from "@/Components/ui/dialog"; } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Badge } from "../ui/badge"; import { Badge } from "../ui/badge";
import { Loader2 } from "lucide-vue-next"; import { Loader2, RotateCcwIcon } from "lucide-vue-next";
import axios from "axios"; import axios from "axios";
const props = defineProps({ const props = defineProps({
@@ -26,6 +26,141 @@ const loading = ref(false);
const previewGenerating = ref(false); const previewGenerating = ref(false);
const previewError = ref(""); const previewError = ref("");
// Image viewer zoom & pan state
const containerRef = ref(null);
const imageRef = ref(null);
const imageScale = ref(1);
const translateX = ref(0);
const translateY = ref(0);
const fitScale = ref(1);
const isDragging = ref(false);
const hasMoved = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const dragStartTX = ref(0);
const dragStartTY = ref(0);
const MAX_SCALE = 8;
const imageCursorClass = computed(() => {
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
return "cursor-default";
});
const initImageView = () => {
const container = containerRef.value;
const img = imageRef.value;
if (!container || !img) return;
const cW = container.clientWidth;
const cH = container.clientHeight;
const iW = img.naturalWidth || cW;
const iH = img.naturalHeight || cH;
const fs = Math.min(1, cW / iW, cH / iH);
fitScale.value = fs;
imageScale.value = fs;
translateX.value = (cW - iW * fs) / 2;
translateY.value = (cH - iH * fs) / 2;
};
const resetImageView = () => {
initImageView();
};
const clampTranslate = (tx, ty, scale) => {
const container = containerRef.value;
const img = imageRef.value;
if (!container || !img) return { tx, ty };
const cW = container.clientWidth;
const cH = container.clientHeight;
const iW = img.naturalWidth * scale;
const iH = img.naturalHeight * scale;
// When image fills the container: clamp so image edges stay within container.
// When image is smaller than container: keep it centered.
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
return {
tx: Math.min(maxX, Math.max(minX, tx)),
ty: Math.min(maxY, Math.max(minY, ty)),
};
};
const zoomAt = (mx, my, factor) => {
const img = imageRef.value;
const iW = img?.naturalWidth ?? 1;
const iH = img?.naturalHeight ?? 1;
const raw = imageScale.value * factor;
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
if (newScale === imageScale.value) return;
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
const clamped = clampTranslate(tx, ty, newScale);
translateX.value = clamped.tx;
translateY.value = clamped.ty;
imageScale.value = newScale;
};
const mousePos = (e) => {
const rect = containerRef.value.getBoundingClientRect();
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
};
const handleImageLoad = () => {
initImageView();
};
const handleWheel = (e) => {
e.preventDefault();
const { mx, my } = mousePos(e);
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
};
const onMouseMove = (e) => {
if (!isDragging.value) return;
const dx = e.clientX - dragStartX.value;
const dy = e.clientY - dragStartY.value;
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
hasMoved.value = true;
}
if (hasMoved.value) {
const clamped = clampTranslate(
dragStartTX.value + dx,
dragStartTY.value + dy,
imageScale.value
);
translateX.value = clamped.tx;
translateY.value = clamped.ty;
}
};
const onMouseUp = () => {
isDragging.value = false;
setTimeout(() => {
hasMoved.value = false;
}, 0);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
const handleMouseDown = (e) => {
if (e.button !== 0) return;
isDragging.value = true;
hasMoved.value = false;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
dragStartTX.value = translateX.value;
dragStartTY.value = translateY.value;
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
onUnmounted(() => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
});
const fileExtension = computed(() => { const fileExtension = computed(() => {
if (props.filename) { if (props.filename) {
return props.filename.split(".").pop()?.toLowerCase() || ""; return props.filename.split(".").pop()?.toLowerCase() || "";
@@ -118,6 +253,10 @@ watch(
previewGenerating.value = false; previewGenerating.value = false;
previewError.value = ""; previewError.value = "";
docxPreviewUrl.value = ""; docxPreviewUrl.value = "";
imageScale.value = 1;
translateX.value = 0;
translateY.value = 0;
fitScale.value = 1;
} }
}, },
{ immediate: true } { immediate: true }
@@ -179,11 +318,51 @@ watch(
<!-- Image Viewer --> <!-- Image Viewer -->
<template v-else-if="viewerType === 'image' && props.src"> <template v-else-if="viewerType === 'image' && props.src">
<div
ref="containerRef"
class="relative h-full overflow-hidden select-none"
:class="imageCursorClass"
@mousedown="handleMouseDown"
@wheel.prevent="handleWheel"
>
<img <img
ref="imageRef"
:src="props.src" :src="props.src"
:alt="props.title" :alt="props.title"
class="max-w-full max-h-full mx-auto object-contain" draggable="false"
class="absolute top-0 left-0 max-w-none"
:style="{
transformOrigin: '0 0',
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
transition: isDragging ? 'none' : 'transform 0.12s ease',
}"
@load="handleImageLoad"
/> />
<!-- Zoom level badge -->
<div
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
>
{{ Math.round(imageScale * 100) }}%
</div>
<!-- Reset button -->
<Button
v-if="imageScale > fitScale + 0.01"
size="icon-sm"
variant="secondary"
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
title="Ponastavi pogled"
@click.stop="resetImageView"
>
<RotateCcwIcon class="h-3 w-3" />
</Button>
<!-- Hint -->
<div
v-if="imageScale <= fitScale + 0.01"
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
>
Kolesce za povečavo / pomanjšavo · Povleči za premik
</div>
</div>
</template> </template>
<!-- Text/CSV/XML Viewer --> <!-- Text/CSV/XML Viewer -->
@@ -0,0 +1,427 @@
<script setup>
import { ref, computed, watch, onUnmounted } from "vue";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerFooter,
DrawerClose,
} from "@/Components/ui/drawer";
import { ScrollArea } from "@/Components/ui/scroll-area";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
import axios from "axios";
const props = defineProps({
show: { type: Boolean, default: false },
src: { type: String, default: "" },
title: { type: String, default: "Dokument" },
mimeType: { type: String, default: "" },
filename: { type: String, default: "" },
});
const emit = defineEmits(["close"]);
const textContent = ref("");
const loading = ref(false);
const previewGenerating = ref(false);
const previewError = ref("");
// Image viewer zoom & pan state
const containerRef = ref(null);
const imageRef = ref(null);
const imageScale = ref(1);
const translateX = ref(0);
const translateY = ref(0);
const fitScale = ref(1);
const isDragging = ref(false);
const hasMoved = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const dragStartTX = ref(0);
const dragStartTY = ref(0);
const MAX_SCALE = 8;
const imageCursorClass = computed(() => {
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
return "cursor-default";
});
const initImageView = () => {
const container = containerRef.value;
const img = imageRef.value;
if (!container || !img) return;
const cW = container.clientWidth;
const cH = container.clientHeight;
const iW = img.naturalWidth || cW;
const iH = img.naturalHeight || cH;
const fs = Math.min(1, cW / iW, cH / iH);
fitScale.value = fs;
imageScale.value = fs;
translateX.value = (cW - iW * fs) / 2;
translateY.value = (cH - iH * fs) / 2;
};
const resetImageView = () => initImageView();
const clampTranslate = (tx, ty, scale) => {
const container = containerRef.value;
const img = imageRef.value;
if (!container || !img) return { tx, ty };
const cW = container.clientWidth;
const cH = container.clientHeight;
const iW = img.naturalWidth * scale;
const iH = img.naturalHeight * scale;
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
return {
tx: Math.min(maxX, Math.max(minX, tx)),
ty: Math.min(maxY, Math.max(minY, ty)),
};
};
const zoomAt = (mx, my, factor) => {
const raw = imageScale.value * factor;
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
if (newScale === imageScale.value) return;
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
const clamped = clampTranslate(tx, ty, newScale);
translateX.value = clamped.tx;
translateY.value = clamped.ty;
imageScale.value = newScale;
};
const mousePos = (e) => {
const rect = containerRef.value.getBoundingClientRect();
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
};
const handleImageLoad = () => initImageView();
const handleWheel = (e) => {
e.preventDefault();
const { mx, my } = mousePos(e);
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
};
// Touch pinch-to-zoom
let lastTouchDist = null;
let lastTouchMidX = null;
let lastTouchMidY = null;
const getTouchDist = (touches) => {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.hypot(dx, dy);
};
const handleTouchStart = (e) => {
if (e.touches.length === 2) {
lastTouchDist = getTouchDist(e.touches);
const rect = containerRef.value.getBoundingClientRect();
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
e.preventDefault();
} else if (e.touches.length === 1) {
isDragging.value = true;
hasMoved.value = false;
dragStartX.value = e.touches[0].clientX;
dragStartY.value = e.touches[0].clientY;
dragStartTX.value = translateX.value;
dragStartTY.value = translateY.value;
}
};
const handleTouchMove = (e) => {
if (e.touches.length === 2 && lastTouchDist !== null) {
e.preventDefault();
const dist = getTouchDist(e.touches);
const factor = dist / lastTouchDist;
const rect = containerRef.value.getBoundingClientRect();
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
zoomAt(midX, midY, factor);
lastTouchDist = dist;
lastTouchMidX = midX;
lastTouchMidY = midY;
} else if (e.touches.length === 1 && isDragging.value) {
const dx = e.touches[0].clientX - dragStartX.value;
const dy = e.touches[0].clientY - dragStartY.value;
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
hasMoved.value = true;
}
if (hasMoved.value) {
const clamped = clampTranslate(
dragStartTX.value + dx,
dragStartTY.value + dy,
imageScale.value
);
translateX.value = clamped.tx;
translateY.value = clamped.ty;
}
}
};
const handleTouchEnd = () => {
lastTouchDist = null;
isDragging.value = false;
setTimeout(() => { hasMoved.value = false; }, 0);
};
const onMouseMove = (e) => {
if (!isDragging.value) return;
const dx = e.clientX - dragStartX.value;
const dy = e.clientY - dragStartY.value;
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) hasMoved.value = true;
if (hasMoved.value) {
const clamped = clampTranslate(
dragStartTX.value + dx,
dragStartTY.value + dy,
imageScale.value
);
translateX.value = clamped.tx;
translateY.value = clamped.ty;
}
};
const onMouseUp = () => {
isDragging.value = false;
setTimeout(() => { hasMoved.value = false; }, 0);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
const handleMouseDown = (e) => {
if (e.button !== 0) return;
isDragging.value = true;
hasMoved.value = false;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
dragStartTX.value = translateX.value;
dragStartTY.value = translateY.value;
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
onUnmounted(() => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
});
const fileExtension = computed(() => {
if (props.filename) return props.filename.split(".").pop()?.toLowerCase() || "";
return "";
});
const viewerType = computed(() => {
const ext = fileExtension.value;
const mime = props.mimeType.toLowerCase();
if (ext === "pdf" || mime === "application/pdf") return "pdf";
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
return "docx";
if (["jpg", "jpeg", "png", "gif", "webp", "heic", "heif"].includes(ext) || mime.startsWith("image/"))
return "image";
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
return "unsupported";
});
const loadTextContent = async () => {
if (!props.src || viewerType.value !== "text") return;
loading.value = true;
try {
const response = await axios.get(props.src);
textContent.value = response.data;
} catch {
textContent.value = "Napaka pri nalaganju vsebine.";
} finally {
loading.value = false;
}
};
const docxPreviewUrl = ref("");
const loadDocxPreview = async () => {
if (!props.src || viewerType.value !== "docx") return;
previewGenerating.value = true;
previewError.value = "";
docxPreviewUrl.value = "";
const maxRetries = 15;
const retryDelay = 2000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await axios.head(props.src, { validateStatus: () => true });
if (response.status >= 200 && response.status < 300) {
docxPreviewUrl.value = props.src;
previewGenerating.value = false;
return;
} else if (response.status === 202) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
} else {
previewError.value = "Napaka pri nalaganju predogleda.";
previewGenerating.value = false;
return;
}
} catch {
previewError.value = "Napaka pri nalaganju predogleda.";
previewGenerating.value = false;
return;
}
}
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
previewGenerating.value = false;
};
watch(
() => [props.show, props.src],
([show]) => {
if (show && viewerType.value === "text") loadTextContent();
if (show && viewerType.value === "docx") loadDocxPreview();
if (!show) {
previewGenerating.value = false;
previewError.value = "";
docxPreviewUrl.value = "";
imageScale.value = 1;
translateX.value = 0;
translateY.value = 0;
fitScale.value = 1;
}
},
{ immediate: true }
);
</script>
<template>
<Drawer :open="show" @update:open="(val) => !val && emit('close')">
<DrawerContent class="flex flex-col h-[95vh]">
<DrawerHeader class="border-b px-4 py-3 shrink-0">
<DrawerTitle class="truncate pr-4">{{ title }}</DrawerTitle>
<div class="mt-1">
<Badge>{{ fileExtension }}</Badge>
</div>
</DrawerHeader>
<!-- Viewer area: flex-1 + min-h-0 works because parent has fixed h-[95vh] -->
<div class="flex-1 min-h-0 overflow-hidden">
<!-- PDF Viewer -->
<template v-if="viewerType === 'pdf' && src">
<iframe :src="src" class="w-full h-full" type="application/pdf" />
</template>
<!-- DOCX Viewer (converted to PDF by backend) -->
<template v-else-if="viewerType === 'docx'">
<div
v-if="previewGenerating"
class="flex flex-col items-center justify-center h-full gap-4"
>
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
</div>
<div
v-else-if="previewError"
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
>
<span>{{ previewError }}</span>
<Button as="a" :href="src" target="_blank" variant="outline">
Prenesi datoteko
</Button>
</div>
<iframe
v-else-if="docxPreviewUrl"
:src="docxPreviewUrl"
class="w-full h-full"
type="application/pdf"
/>
</template>
<!-- Image Viewer with touch pinch-to-zoom -->
<template v-else-if="viewerType === 'image' && src">
<div
ref="containerRef"
class="relative h-full overflow-hidden select-none"
:class="imageCursorClass"
@mousedown="handleMouseDown"
@wheel.prevent="handleWheel"
@touchstart.prevent="handleTouchStart"
@touchmove.prevent="handleTouchMove"
@touchend="handleTouchEnd"
>
<img
ref="imageRef"
:src="src"
:alt="title"
draggable="false"
class="absolute top-0 left-0 max-w-none"
:style="{
transformOrigin: '0 0',
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
transition: isDragging ? 'none' : 'transform 0.12s ease',
}"
@load="handleImageLoad"
/>
<div
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
>
{{ Math.round(imageScale * 100) }}%
</div>
<Button
v-if="imageScale > fitScale + 0.01"
size="icon-sm"
variant="secondary"
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
title="Ponastavi pogled"
@click.stop="resetImageView"
>
<RotateCcwIcon class="h-3 w-3" />
</Button>
<div
v-if="imageScale <= fitScale + 0.01"
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
>
Ščipni za povečavo · Povleči za premik
</div>
</div>
</template>
<!-- Text/CSV/XML Viewer -->
<template v-else-if="viewerType === 'text'">
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="animate-pulse text-gray-500">Nalaganje...</div>
</div>
<ScrollArea v-else class="h-full">
<pre
class="p-4 bg-gray-50 dark:bg-gray-900 text-sm whitespace-pre-wrap wrap-break-word"
>{{ textContent }}</pre
>
</ScrollArea>
</template>
<!-- Unsupported -->
<template v-else-if="viewerType === 'unsupported'">
<div
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
>
<span>Predogled ni na voljo za to vrsto datoteke.</span>
<Button as="a" :href="src" target="_blank" variant="outline">
Prenesi datoteko
</Button>
</div>
</template>
<div v-else class="flex items-center justify-center h-full text-sm text-gray-500">
Ni dokumenta za prikaz.
</div>
</div>
<DrawerFooter class="border-t shrink-0 px-4 py-3">
<DrawerClose as-child>
<Button variant="outline" class="w-full" @click="emit('close')">Zapri</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</template>
+87 -113
View File
@@ -1,13 +1,17 @@
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { import {
faLocationDot, MapPin,
faPhone, Phone,
faEnvelope, Mail,
faLandmark, Landmark,
faChevronDown, ChevronDown,
} from "@fortawesome/free-solid-svg-icons"; CheckIcon,
CircleCheckIcon,
CircleCheckBigIcon,
} from "lucide-vue-next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import Badge from "./ui/badge/Badge.vue";
const props = defineProps({ const props = defineProps({
person: { type: Object, required: true }, person: { type: Object, required: true },
@@ -76,13 +80,6 @@ watch(
} }
} }
); );
function maskIban(iban) {
if (!iban || typeof iban !== "string") return null;
const clean = iban.replace(/\s+/g, "");
if (clean.length <= 8) return clean;
return `${clean.slice(0, 4)} **** **** ${clean.slice(-4)}`;
}
</script> </script>
<template> <template>
@@ -90,11 +87,11 @@ function maskIban(iban) {
<div class="text-sm"> <div class="text-sm">
<div class="mt-2 flex flex-wrap gap-1.5"> <div class="mt-2 flex flex-wrap gap-1.5">
<span v-if="primaryAddress" class="pill pill-slate" title="Naslov"> <span v-if="primaryAddress" class="pill pill-slate" title="Naslov">
<FontAwesomeIcon :icon="faLocationDot" class="w-4 h-4 mr-1" /> <MapPin class="w-4 h-4 mr-1" />
<span class="truncate max-w-[9rem]">{{ primaryAddress.address }}</span> <span class="truncate max-w-36">{{ primaryAddress.address }}</span>
</span> </span>
<span v-if="summaryPhones.length" class="pill pill-indigo" title="Telefon"> <span v-if="summaryPhones.length" class="pill pill-indigo" title="Telefon">
<FontAwesomeIcon :icon="faPhone" class="w-4 h-4 mr-1" /> <Phone class="w-4 h-4 mr-1" />
{{ summaryPhones[0].nu {{ summaryPhones[0].nu
}}<span }}<span
v-if=" v-if="
@@ -108,12 +105,12 @@ function maskIban(iban) {
> >
</span> </span>
<span v-if="primaryEmail && showMore" class="pill pill-default" title="E-pošta"> <span v-if="primaryEmail && showMore" class="pill pill-default" title="E-pošta">
<FontAwesomeIcon :icon="faEnvelope" class="w-4 h-4 mr-1" /> <Mail class="w-4 h-4 mr-1" />
<span class="truncate max-w-[9rem]">{{ primaryEmail }}</span> <span class="truncate max-w-36">{{ primaryEmail }}</span>
</span> </span>
<span v-if="bankIban" class="pill pill-emerald" title="TRR (zadnji dodan)"> <span v-if="bankIban" class="pill pill-emerald" title="TRR (zadnji dodan)">
<FontAwesomeIcon :icon="faLandmark" class="w-4 h-4 mr-1" /> <Landmark class="w-4 h-4 mr-1" />
{{ maskIban(bankIban) }} {{ bankIban }}
</span> </span>
</div> </div>
@@ -129,7 +126,7 @@ function maskIban(iban) {
</div> </div>
<div v-if="bankIban"> <div v-if="bankIban">
<div class="label">TRR (zadnji)</div> <div class="label">TRR (zadnji)</div>
<div class="value font-mono">{{ maskIban(bankIban) }}</div> <div class="value font-mono">{{ bankIban }}</div>
</div> </div>
<div v-if="primaryEmail"> <div v-if="primaryEmail">
<div class="label">Epošta</div> <div class="label">Epošta</div>
@@ -142,8 +139,7 @@ function maskIban(iban) {
class="mt-3 inline-flex items-center text-[11px] font-medium text-indigo-600 hover:text-indigo-700 focus:outline-none" class="mt-3 inline-flex items-center text-[11px] font-medium text-indigo-600 hover:text-indigo-700 focus:outline-none"
@click="showMore = !showMore" @click="showMore = !showMore"
> >
<FontAwesomeIcon <ChevronDown
:icon="faChevronDown"
:class="[ :class="[
'w-3 h-3 mr-1 transition-transform', 'w-3 h-3 mr-1 transition-transform',
showMore ? 'rotate-180' : 'rotate-0', showMore ? 'rotate-180' : 'rotate-0',
@@ -154,83 +150,91 @@ function maskIban(iban) {
</div> </div>
<!-- Segmented Tabs --> <!-- Segmented Tabs -->
<div class="mt-5"> <div class="mt-4 text-sm">
<div class="relative"> <Tabs :default-value="activeTab" @update:model-value="activeTab = $event">
<div <TabsList class="w-full">
class="flex w-full text-[11px] font-medium rounded-lg border bg-gray-50 overflow-hidden" <TabsTrigger value="addresses" class="flex-1">
> <div class="flex flex-row items-center gap-1">
<button <MapPin class="w-3.5 h-3.5 shrink-0" />
type="button"
@click="activeTab = 'addresses'"
:class="['seg-btn', activeTab === 'addresses' && 'seg-active']"
>
<FontAwesomeIcon :icon="faLocationDot" class="w-3.5 h-3.5 mr-1 shrink-0" />
<span class="truncate">Naslovi ({{ allAddresses.length }})</span> <span class="truncate">Naslovi ({{ allAddresses.length }})</span>
</button> </div>
<button </TabsTrigger>
type="button" <TabsTrigger value="phones" class="flex-1">
@click="activeTab = 'phones'" <div class="flex flex-row items-center gap-1">
:class="['seg-btn', activeTab === 'phones' && 'seg-active']" <Phone class="w-3.5 h-3.5 shrink-0" />
>
<FontAwesomeIcon :icon="faPhone" class="w-3.5 h-3.5 mr-1 shrink-0" />
<span class="truncate">Telefoni ({{ allPhones.length }})</span> <span class="truncate">Telefoni ({{ allPhones.length }})</span>
</button>
<button
type="button"
@click="activeTab = 'emails'"
:class="['seg-btn', activeTab === 'emails' && 'seg-active']"
>
<FontAwesomeIcon :icon="faEnvelope" class="w-3.5 h-3.5 mr-1 shrink-0" />
<span class="truncate">Epošta ({{ allEmails.length }})</span>
</button>
</div> </div>
</TabsTrigger>
<TabsTrigger value="emails" class="flex-1">
<div class="flex flex-row items-center gap-1">
<Mail class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">E&#8209;pošta ({{ allEmails.length }})</span>
</div> </div>
</TabsTrigger>
</TabsList>
<div class="mt-3 rounded-md border bg-white/60 p-2"> <TabsContent value="addresses" class="mt-2 rounded-md border">
<!-- Addresses --> <div v-if="!allAddresses.length" class="p-2 text-center">Ni naslovov.</div>
<div v-if="activeTab === 'addresses'"> <div
<div v-if="!allAddresses.length" class="empty">Ni naslovov.</div> v-for="(a, idx) in allAddresses"
<div v-for="(a, idx) in allAddresses" :key="a.id || idx" class="item-row"> :key="a.id || idx"
<div class="font-medium text-gray-800">{{ a.address }}</div> class="flex flex-row gap-1 items-center p-2 border-b last:border-0"
<div v-if="a.country" class="sub">{{ a.country }}</div>
</div>
</div>
<!-- Phones -->
<div v-else-if="activeTab === 'phones'">
<div v-if="!allPhones.length" class="empty">Ni telefonov.</div>
<div v-for="(p, idx) in allPhones" :key="p.id || idx" class="item-row">
<div class="font-medium text-gray-800">
{{ p.nu }}
<span
v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name"
class="sub ml-1"
>({{ p.type?.name || phoneTypes[p.type_id] }})</span
> >
<p class="font-bold wrap-break-word max-w-60">{{ a.address }}</p>
<Badge v-if="a.country" variant="outline" class="text-xs">{{
a.country
}}</Badge>
</div> </div>
</TabsContent>
<TabsContent value="phones" class="mt-2 rounded-md border">
<div v-if="!allPhones.length" class="p-2 text-center">Ni telefonov.</div>
<div
v-for="(p, idx) in allPhones"
:key="p.id || idx"
class="flex flex-row gap-1 items-center p-2 border-b last:border-0"
>
<p class="font-bold wrap-break-word max-w-60">{{ p.nu }}</p>
<CircleCheckBigIcon v-if="p.validated" class="text-green-500" :size="16" />
<Badge variant="outline" v-if="p.label" class="text-xs font-medium">{{
p.label
}}</Badge>
<Badge
variant="outline"
v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name"
>
{{ p.type?.name || phoneTypes[p.type_id] }}
</Badge>
</div> </div>
</TabsContent>
<TabsContent value="emails" class="mt-2 rounded-md border">
<div v-if="!allEmails.length" class="p-2 text-center">Ni e-poštnih naslovov.</div>
<div
v-for="(e, idx) in allEmails"
:key="e.id || idx"
class="flex flex-row gap-1 items-center p-2 border-b last:border-0"
>
<p class="font-bold wrap-break-word max-w-60">
{{ e.value }}
</p>
<CircleCheckBigIcon v-if="e.valid" class="text-green-500" :size="16" />
<Badge v-if="e.label" variant="outline">({{ e.label }})</Badge>
</div> </div>
<!-- Emails --> </TabsContent>
<div v-else-if="activeTab === 'emails'"> </Tabs>
<div v-if="!allEmails.length" class="empty">Ni e-poštnih naslovov.</div>
<div v-for="(e, idx) in allEmails" :key="e.id || idx" class="item-row">
<div class="font-medium text-gray-800">
{{ e.value }}<span v-if="e.label" class="sub ml-1">({{ e.label }})</span>
</div>
</div>
</div>
<!-- (TRR tab removed; last bank account surfaced in summary) -->
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* Basic utility replacements (no Tailwind processor here) */
.pill { .pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
max-width: 100%; max-width: 100%;
border-radius: 9999px; border-radius: 9999px;
padding: 0.35rem 0.75rem; /* slightly larger */ padding: 0.35rem 0.75rem;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
line-height: 1.15; line-height: 1.15;
@@ -253,36 +257,6 @@ function maskIban(iban) {
color: #047857; color: #047857;
} }
.seg-btn {
flex: 1 1 0;
min-width: 0; /* allow flex item to shrink below intrinsic size */
white-space: nowrap;
padding: 0.5rem 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
border-right: 1px solid #e5e7eb;
font-size: 11px;
background: transparent;
color: #4b5563;
transition: background 0.15s, color 0.15s;
overflow: hidden;
}
.seg-btn:last-child {
border-right: none;
}
.seg-btn:hover {
background: #ffffffb3;
color: #1f2937;
}
.seg-active {
background: #fff;
color: #111827;
font-weight: 600;
box-shadow: inset 0 0 0 1px #e5e7eb;
}
.item-row { .item-row {
padding: 0.375rem 0; padding: 0.375rem 0;
border-bottom: 1px dashed #e5e7eb; border-bottom: 1px dashed #e5e7eb;
@@ -3,7 +3,8 @@ import { computed, ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate"; import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod"; import * as z from "zod";
import { router } from "@inertiajs/vue3"; import { router, usePage } from "@inertiajs/vue3";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import CreateDialog from "../Dialogs/CreateDialog.vue"; import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue"; import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
@@ -27,12 +28,24 @@ const props = defineProps({
const emit = defineEmits(["close"]); const emit = defineEmits(["close"]);
// Decisions with auto_mail = true from shared Inertia data
const page = usePage();
const decisionOptions = computed(() =>
(page.props.auto_mail_decisions ?? []).map((d) => ({
value: String(d.id),
label: d.name,
}))
);
// Zod schema for form validation // Zod schema for form validation
const formSchema = toTypedSchema( const formSchema = toTypedSchema(
z.object({ z.object({
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."), value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
label: z.string().optional(), label: z.string().optional(),
receive_auto_mails: z.boolean().optional(), receive_auto_mails: z.boolean().optional(),
valid: z.boolean().default(true),
failed: z.boolean().default(false),
decision_ids: z.array(z.string()).optional().default([]),
}) })
); );
@@ -43,9 +56,15 @@ const form = useForm({
value: "", value: "",
label: "", label: "",
receive_auto_mails: false, receive_auto_mails: false,
valid: true,
failed: false,
decision_ids: [],
}, },
}); });
// Whether to limit sending to specific decisions (UI-only toggle)
const limitToDecisions = ref(false);
const processing = ref(false); const processing = ref(false);
const close = () => { const close = () => {
@@ -57,22 +76,46 @@ const close = () => {
}; };
const resetForm = () => { const resetForm = () => {
limitToDecisions.value = false;
form.resetForm({ form.resetForm({
values: { values: {
value: "", value: "",
label: "", label: "",
receive_auto_mails: false, receive_auto_mails: false,
valid: true,
failed: false,
decision_ids: [],
}, },
}); });
}; };
// When auto mails is disabled, collapse the decision filter
watch(
() => form.values.receive_auto_mails,
(val) => {
if (!val) {
limitToDecisions.value = false;
}
}
);
// When limit toggle is turned off, clear the selection
watch(limitToDecisions, (val) => {
if (!val) {
form.setFieldValue("decision_ids", []);
}
});
const create = async () => { const create = async () => {
processing.value = true; processing.value = true;
const { values } = form; const payload = {
...form.values,
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
};
router.post( router.post(
route("person.email.create", props.person), route("person.email.create", props.person),
values, payload,
{ {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
@@ -98,11 +141,14 @@ const create = async () => {
const update = async () => { const update = async () => {
processing.value = true; processing.value = true;
const { values } = form; const payload = {
...form.values,
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
};
router.put( router.put(
route("person.email.update", { person: props.person, email_id: props.id }), route("person.email.update", { person: props.person, email_id: props.id }),
values, payload,
{ {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
@@ -136,10 +182,15 @@ watch(
const list = Array.isArray(props.person?.emails) ? props.person.emails : []; const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
const email = list.find((e) => e.id === props.id); const email = list.find((e) => e.id === props.id);
if (email) { if (email) {
const existingDecisionIds = (email.preferences?.decision_ids ?? []).map(String);
limitToDecisions.value = existingDecisionIds.length > 0;
form.setValues({ form.setValues({
value: email.value ?? email.email ?? email.address ?? "", value: email.value ?? email.email ?? email.address ?? "",
label: email.label ?? "", label: email.label ?? "",
receive_auto_mails: !!email.receive_auto_mails, receive_auto_mails: !!email.receive_auto_mails,
valid: email.valid !== undefined ? !!email.valid : true,
failed: !!email.failed,
decision_ids: existingDecisionIds,
}); });
} else { } else {
resetForm(); resetForm();
@@ -228,6 +279,58 @@ const onConfirm = () => {
</div> </div>
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ value, handleChange }" name="valid">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Veljavna</FormLabel>
</div>
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="failed">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Neuspešna dostava</FormLabel>
</div>
</FormItem>
</FormField>
<!-- Limit to specific decisions only shown when receive_auto_mails is on and decisions exist -->
<template v-if="(props.person?.client || isClientContext) && form.values.receive_auto_mails && decisionOptions.length > 0">
<div class="flex flex-row items-start space-x-3 space-y-0">
<Switch
:model-value="limitToDecisions"
@update:model-value="(val) => (limitToDecisions = val)"
/>
<div class="space-y-1 leading-none">
<label class="text-sm font-medium leading-none cursor-pointer" @click="limitToDecisions = !limitToDecisions">
Omeji na posamezne odločitve
</label>
</div>
</div>
<FormField v-if="limitToDecisions" v-slot="{ value, handleChange }" name="decision_ids">
<FormItem>
<FormLabel>Odločitve, za katere se pošlje e-pošta</FormLabel>
<FormControl>
<AppMultiSelect
:model-value="value ?? []"
:items="decisionOptions"
placeholder="Izberi odločitve..."
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</template>
</div> </div>
</form> </form>
</component> </component>
@@ -0,0 +1,483 @@
<script setup>
import { ref, watch, computed, nextTick } from "vue";
import axios from "axios";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { router, usePage } from "@inertiajs/vue3";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Button } from "@/Components/ui/button";
import { ScrollArea } from "@/Components/ui/scroll-area";
const props = defineProps({
show: { type: Boolean, default: false },
email: { type: Object, default: null },
clientCaseUuid: { type: String, default: null },
emailTemplates: { type: Array, default: () => [] },
mailProfiles: { type: Array, default: () => [] },
});
const emit = defineEmits(["close"]);
const page = usePage();
const pageProps = computed(() => page?.props ?? {});
const pageEmailTemplates = computed(() => {
const fromProps =
Array.isArray(props.emailTemplates) && props.emailTemplates.length
? props.emailTemplates
: null;
return fromProps ?? pageProps.value?.email_templates ?? [];
});
const pageMailProfiles = computed(() => {
const fromProps =
Array.isArray(props.mailProfiles) && props.mailProfiles.length
? props.mailProfiles
: null;
return fromProps ?? pageProps.value?.mail_profiles ?? [];
});
// Form schema
const formSchema = toTypedSchema(
z.object({
subject: z.string().min(1, "Zadeva je obvezna.").max(255),
html_body: z.string().nullable().optional(),
body_text: z.string().max(10000).nullable().optional(),
template_id: z.number().nullable().optional(),
mail_profile_id: z.number().nullable().optional(),
contract_uuid: z.string().nullable().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
subject: "",
html_body: "",
body_text: "",
template_id: null,
mail_profile_id: null,
contract_uuid: null,
},
});
const processing = ref(false);
const contractsForCase = ref([]);
const hasBodyText = ref(false); // whether selected template uses {{body_text}}
// WYSIWYG iframe
const iframeRef = ref(null);
let iframeSyncing = false;
function ensureFullDoc(html) {
if (!html) {
return '<!doctype html><html><head><meta charset="utf-8" /></head><body></body></html>';
}
if (/<html[\s\S]*<\/html>/i.test(html)) return html;
return `<!doctype html><html><head><meta charset="utf-8" /></head><body>${html}</body></html>`;
}
function writeIframeDocument(html) {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
const full = ensureFullDoc(html ?? form.values.html_body ?? "");
doc.open();
doc.write(full);
doc.close();
try {
doc.body.setAttribute("spellcheck", "false");
} catch {}
}
function initIframeEditor(html) {
writeIframeDocument(html);
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.designMode = "on";
} catch {}
const syncHandler = () => {
if (iframeSyncing) return;
try {
iframeSyncing = true;
const full = doc.documentElement.outerHTML;
form.setFieldValue("html_body", full);
} finally {
iframeSyncing = false;
}
};
doc.removeEventListener("input", syncHandler);
doc.removeEventListener("keyup", syncHandler);
doc.addEventListener("input", syncHandler);
doc.addEventListener("keyup", syncHandler);
}
function iframeExec(command) {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.body.focus();
} catch {}
try {
doc.execCommand(command, false, null);
} catch (e) {
console.warn("execCommand failed", command, e);
}
}
// Load template preview from server
const loadingPreview = ref(false);
const updateFromTemplate = async () => {
if (!form.values.template_id || !props.clientCaseUuid) return;
loadingPreview.value = true;
try {
const url = route("clientCase.email.preview", {
client_case: props.clientCaseUuid,
email_id: props.email?.id,
});
const { data } = await axios.post(url, {
template_id: form.values.template_id,
contract_uuid: form.values.contract_uuid || null,
body_text: form.values.body_text || "",
});
const hadBodyText = hasBodyText.value;
hasBodyText.value = !!data?.has_body_text;
// Pre-fill body_text from text_template when the placeholder is present and field is empty
if (data?.has_body_text && !hadBodyText) {
const tpl = pageEmailTemplates.value.find((t) => t.id === form.values.template_id);
if (tpl?.text_template && !form.values.body_text) {
form.setFieldValue("body_text", tpl.text_template);
}
}
if (data?.subject) {
form.setFieldValue("subject", data.subject);
}
const html = data?.html ?? "";
form.setFieldValue("html_body", html);
await nextTick();
initIframeEditor(html);
} catch (e) {
// ignore
} finally {
loadingPreview.value = false;
}
};
watch(
() => form.values.template_id,
() => {
updateFromTemplate();
}
);
watch(
() => form.values.contract_uuid,
() => {
if (form.values.template_id) {
updateFromTemplate();
}
}
);
// Re-preview when body_text changes (debounce-like: only when a template is active)
watch(
() => form.values.body_text,
() => {
if (form.values.template_id && hasBodyText.value) {
updateFromTemplate();
}
}
);
const loadContractsForCase = async () => {
try {
const url = route("clientCase.contracts.list", {
client_case: props.clientCaseUuid,
});
const res = await fetch(url, {
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin",
});
const json = await res.json();
contractsForCase.value = Array.isArray(json?.data) ? json.data : [];
} catch (e) {
contractsForCase.value = [];
}
};
watch(
() => props.show,
async (newVal) => {
if (newVal) {
form.resetForm({
values: {
subject: "",
html_body: "",
body_text: "",
template_id: null,
mail_profile_id: pageMailProfiles.value?.[0]?.id ?? null,
contract_uuid: null,
},
});
hasBodyText.value = false;
contractsForCase.value = [];
await loadContractsForCase();
// Init empty iframe
await nextTick();
initIframeEditor("");
}
}
);
const closeDialog = () => {
emit("close");
};
const onSubmit = form.handleSubmit((values) => {
if (!props.email || !props.clientCaseUuid) return;
processing.value = true;
router.post(
route("clientCase.email.send", {
client_case: props.clientCaseUuid,
email_id: props.email.id,
}),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
closeDialog();
},
onError: () => {
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
});
const open = computed({
get: () => props.show,
set: (value) => {
if (!value) closeDialog();
},
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Pošlji e-pošto</DialogTitle>
<DialogDescription>
<p class="text-sm text-gray-600">
Prejemnik:
<span class="font-mono">{{ email?.value || email?.email || email?.address }}</span>
</p>
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[70vh] pr-1">
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
<!-- Mail profile -->
<FormField v-slot="{ value, handleChange }" name="mail_profile_id">
<FormItem>
<FormLabel>E-poštni profil</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="p in pageMailProfiles"
:key="p.id"
:value="p.id"
>
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<!-- Contract -->
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
<FormItem>
<FormLabel>Pogodba</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="c in contractsForCase"
:key="c.uuid"
:value="c.uuid"
>
{{ c.reference || c.uuid }}
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-gray-500">
Izberite pogodbo za zapolnitev spremenljivk v predlogi.
</p>
<FormMessage />
</FormItem>
</FormField>
<!-- Template -->
<FormField v-slot="{ value, handleChange }" name="template_id">
<FormItem>
<FormLabel>Predloga</FormLabel>
<Select
:model-value="value"
@update:model-value="handleChange"
:disabled="loadingPreview"
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="t in pageEmailTemplates"
:key="t.id"
:value="t.id"
>
{{ t.name || "Predloga #" + t.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<!-- Subject -->
<FormField v-slot="{ componentField }" name="subject">
<FormItem>
<FormLabel>Zadeva</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Zadeva e-poštnega sporočila..."
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- body_text textarea shown only when the template uses {{body_text}} -->
<FormField v-if="hasBodyText" v-slot="{ componentField }" name="body_text">
<FormItem>
<FormLabel>Besedilo sporočila</FormLabel>
<FormControl>
<Textarea
placeholder="Vnesite besedilo, ki se vstavi na mesto {{body_text}} v predlogi..."
class="min-h-[120px] resize-y"
v-bind="componentField"
/>
</FormControl>
<p class="mt-1 text-xs text-gray-500">
Besedilo se vstavi na oznako <code>&#123;&#123;body_text&#125;&#125;</code> v predlogi. Besedilo ne podpira spremenljivk.
</p>
<FormMessage />
</FormItem>
</FormField>
<!-- WYSIWYG body editor -->
<div>
<label class="text-sm font-medium leading-none">Vsebina</label>
<!-- Toolbar -->
<div class="flex gap-1 mt-2 mb-1 border rounded-t-md bg-gray-50 p-1">
<Button
type="button"
size="sm"
variant="ghost"
class="font-bold px-2 py-1 h-7"
title="Krepko (Ctrl+B)"
@click="iframeExec('bold')"
>B</Button>
<Button
type="button"
size="sm"
variant="ghost"
class="italic px-2 py-1 h-7"
title="Poševno (Ctrl+I)"
@click="iframeExec('italic')"
>I</Button>
<Button
type="button"
size="sm"
variant="ghost"
class="underline px-2 py-1 h-7"
title="Podčrtano (Ctrl+U)"
@click="iframeExec('underline')"
>U</Button>
</div>
<iframe
ref="iframeRef"
class="w-full border rounded-b-md bg-white"
style="min-height: 240px; max-height: 360px"
frameborder="0"
sandbox="allow-same-origin allow-scripts"
/>
<p class="mt-1 text-xs text-gray-500">
Kliknite v vsebino in začnite pisati. Izberite predlogo za samodejno zapolnitev.
</p>
</div>
</form>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="closeDialog" :disabled="processing">
Prekliči
</Button>
<Button
@click="onSubmit"
:disabled="processing || !form.values.subject"
>
{{ processing ? "Pošiljanje..." : "Pošlji" }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -8,14 +8,16 @@ import {
} from "@/Components/ui/dropdown-menu"; } from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card"; import { Card } from "@/Components/ui/card";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { EllipsisVertical } from "lucide-vue-next"; import { CircleCheckBigIcon, CircleXIcon, EllipsisVertical, MailIcon } from "lucide-vue-next";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
const props = defineProps({ const props = defineProps({
person: Object, person: Object,
edit: { type: Boolean, default: true }, edit: { type: Boolean, default: true },
enableEmail: { type: Boolean, default: false },
}); });
const emit = defineEmits(["add", "edit", "delete"]); const emit = defineEmits(["add", "edit", "delete", "email"]);
const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []); const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []);
@@ -44,6 +46,16 @@ const handleDelete = (id, label) => emit("delete", id, label);
</span> </span>
</div> </div>
<div v-if="edit"> <div v-if="edit">
<div class="flex items-center gap-1">
<Button
v-if="enableEmail"
@click="$emit('email', email)"
title="Pošlji e-pošto"
size="icon"
variant="ghost"
>
<MailIcon :size="18" />
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" title="Možnosti"> <Button variant="ghost" size="icon" title="Možnosti">
@@ -68,9 +80,26 @@ const handleDelete = (id, label) => emit("delete", id, label);
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
</div>
<div class="p-1"> <div class="p-1">
<p class="font-medium text-gray-900 leading-relaxed"> <p class="font-medium text-gray-900 leading-relaxed flex gap-1 items-center">
{{ email?.value || email?.email || email?.address || "-" }} {{ email?.value || email?.email || email?.address || "-" }}
<TooltipProvider v-if="email?.valid">
<Tooltip>
<TooltipTrigger as-child>
<CircleCheckBigIcon color="#3e9392" :size="18" />
</TooltipTrigger>
<TooltipContent>Veljavna</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider v-if="email?.failed">
<Tooltip>
<TooltipTrigger as-child>
<CircleXIcon color="#dc2626" :size="18" />
</TooltipTrigger>
<TooltipContent>Neuspešna dostava</TooltipContent>
</Tooltip>
</TooltipProvider>
</p> </p>
<p <p
v-if="email?.note" v-if="email?.note"
@@ -30,6 +30,7 @@ import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue"; import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue"; import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue"; import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
import PersonInfoEmailDialog from "./PersonInfoEmailDialog.vue";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
const props = defineProps({ const props = defineProps({
@@ -58,6 +59,9 @@ const props = defineProps({
smsProfiles: { type: Array, default: () => [] }, smsProfiles: { type: Array, default: () => [] },
smsSenders: { type: Array, default: () => [] }, smsSenders: { type: Array, default: () => [] },
smsTemplates: { type: Array, default: () => [] }, smsTemplates: { type: Array, default: () => [] },
enableEmail: { type: Boolean, default: false },
emailTemplates: { type: Array, default: () => [] },
mailProfiles: { type: Array, default: () => [] },
}); });
// Dialog states // Dialog states
@@ -91,6 +95,10 @@ const confirm = ref({
const showSmsDialog = ref(false); const showSmsDialog = ref(false);
const smsTargetPhone = ref(null); const smsTargetPhone = ref(null);
// Email dialog state
const showEmailDialog = ref(false);
const emailTarget = ref(null);
// Person handlers // Person handlers
const openDrawerUpdateClient = () => { const openDrawerUpdateClient = () => {
drawerUpdatePerson.value = true; drawerUpdatePerson.value = true;
@@ -251,6 +259,18 @@ const closeSmsDialog = () => {
smsTargetPhone.value = null; smsTargetPhone.value = null;
}; };
// Email dialog handlers
const openEmailDialog = (email) => {
if (!props.enableEmail || !props.clientCaseUuid) return;
emailTarget.value = email;
showEmailDialog.value = true;
};
const closeEmailDialog = () => {
showEmailDialog.value = false;
emailTarget.value = null;
};
// Tab event handlers // Tab event handlers
const handlePersonEdit = () => openDrawerUpdateClient(); const handlePersonEdit = () => openDrawerUpdateClient();
@@ -266,6 +286,7 @@ const handlePhoneSms = (phone) => openSmsDialog(phone);
const handleEmailAdd = () => openDrawerAddEmail(false, 0); const handleEmailAdd = () => openDrawerAddEmail(false, 0);
const handleEmailEdit = (id) => openDrawerAddEmail(true, id); const handleEmailEdit = (id) => openDrawerAddEmail(true, id);
const handleEmailDelete = (id, label) => openConfirm("email", id, label); const handleEmailDelete = (id, label) => openConfirm("email", id, label);
const handleEmailSend = (email) => openEmailDialog(email);
const handleTrrAdd = () => openDrawerAddTrr(false, 0); const handleTrrAdd = () => openDrawerAddTrr(false, 0);
const handleTrrEdit = (id) => openDrawerAddTrr(true, id); const handleTrrEdit = (id) => openDrawerAddTrr(true, id);
@@ -418,9 +439,11 @@ const switchToTab = (tab) => {
<PersonInfoEmailsTab <PersonInfoEmailsTab
:person="person" :person="person"
:edit="edit" :edit="edit"
:enable-email="enableEmail && !!clientCaseUuid"
@add="handleEmailAdd" @add="handleEmailAdd"
@edit="handleEmailEdit" @edit="handleEmailEdit"
@delete="handleEmailDelete" @delete="handleEmailDelete"
@email="handleEmailSend"
/> />
</TabsContent> </TabsContent>
@@ -534,4 +557,15 @@ const switchToTab = (tab) => {
:sms-templates="smsTemplates" :sms-templates="smsTemplates"
@close="closeSmsDialog" @close="closeSmsDialog"
/> />
<!-- Email Dialog -->
<PersonInfoEmailDialog
v-if="clientCaseUuid"
:show="showEmailDialog"
:email="emailTarget"
:client-case-uuid="clientCaseUuid"
:email-templates="emailTemplates"
:mail-profiles="mailProfiles"
@close="closeEmailDialog"
/>
</template> </template>
@@ -25,6 +25,7 @@ import {
} from "@/Components/ui/select"; } from "@/Components/ui/select";
import { Switch } from "@/Components/ui/switch"; import { Switch } from "@/Components/ui/switch";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { ScrollArea } from "@/Components/ui/scroll-area";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
@@ -452,7 +453,8 @@ const open = computed({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form @submit.prevent="onSubmit" class="space-y-4"> <ScrollArea class="max-h-[65vh] pr-1">
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField v-slot="{ value, handleChange }" name="profile_id"> <FormField v-slot="{ value, handleChange }" name="profile_id">
<FormItem> <FormItem>
@@ -582,8 +584,8 @@ const open = computed({
</span> </span>
</div> </div>
<p class="text-[11px] text-gray-500 leading-snug"> <p class="text-[11px] text-gray-500 leading-snug">
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake, ki Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake,
ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del ki ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS2). V tem 7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS2). V tem
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših
@@ -604,6 +606,7 @@ const open = computed({
</FormItem> </FormItem>
</FormField> </FormField>
</form> </form>
</ScrollArea>
<DialogFooter> <DialogFooter>
<Button variant="outline" @click="closeSmsDialog" :disabled="processing"> <Button variant="outline" @click="closeSmsDialog" :disabled="processing">
@@ -84,8 +84,8 @@ const summaryText = computed(() => {
const found = props.items.find((i) => String(i.value) === String(v)); const found = props.items.find((i) => String(i.value) === String(v));
return found?.label || v; return found?.label || v;
}); });
if (labels.length <= 3) return labels.join(', '); if (labels.length <= 3) return labels.join(", ");
const firstThree = labels.slice(0, 3).join(', '); const firstThree = labels.slice(0, 3).join(", ");
const remaining = labels.length - 3; const remaining = labels.length - 3;
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
}); });
@@ -154,7 +154,7 @@ const summaryText = computed(() => {
:variant="chipVariant" :variant="chipVariant"
class="flex items-center gap-1" class="flex items-center gap-1"
> >
<span class="truncate max-w-[140px]"> <span class="truncate max-w-35">
{{ items.find((i) => String(i.value) === String(val))?.label || val }} {{ items.find((i) => String(i.value) === String(val))?.label || val }}
</span> </span>
<button <button
@@ -0,0 +1,177 @@
<script setup>
import { CalendarIcon, XIcon } from "lucide-vue-next";
import { computed, ref } from "vue";
import { cn } from "@/lib/utils";
import { Button } from "@/Components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import { RangeCalendar } from "@/Components/ui/range-calendar";
import {
DateFormatter,
getLocalTimeZone,
today,
parseDate,
CalendarDate,
} from "@internationalized/date";
const props = defineProps({
modelValue: {
type: Object,
default: () => ({ start: null, end: null }),
},
placeholder: {
type: String,
default: "Izberi datumski obseg",
},
disabled: {
type: Boolean,
default: false,
},
buttonClass: {
type: String,
default: "w-[280px]",
},
locale: {
type: String,
default: "sl-SI",
},
numberOfMonths: {
type: Number,
default: 2,
},
minValue: {
type: Object,
default: undefined,
},
maxValue: {
type: Object,
default: undefined,
},
clearable: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["update:modelValue"]);
const open = ref(false);
const df = new DateFormatter(props.locale, {
dateStyle: "medium",
});
// Check if there's a selected value
const hasValue = computed(() => {
const val = props.modelValue;
return val?.start || val?.end;
});
// Convert string dates to CalendarDate objects for the calendar
const calendarValue = computed({
get() {
const val = props.modelValue;
if (!val) return undefined;
let start = null;
let end = null;
if (val.start) {
if (typeof val.start === "string") {
start = parseDate(val.start);
} else if (val.start instanceof CalendarDate) {
start = val.start;
}
}
if (val.end) {
if (typeof val.end === "string") {
end = parseDate(val.end);
} else if (val.end instanceof CalendarDate) {
end = val.end;
}
}
if (!start && !end) return undefined;
return { start, end };
},
set(newValue) {
if (!newValue) {
emit("update:modelValue", { start: null, end: null });
return;
}
// Convert CalendarDate to ISO string (YYYY-MM-DD) for easier handling
const result = {
start: newValue.start ? newValue.start.toString() : null,
end: newValue.end ? newValue.end.toString() : null,
};
emit("update:modelValue", result);
// Close popover when both dates are selected
if (result.start && result.end) {
open.value = false;
}
},
});
const displayText = computed(() => {
const val = calendarValue.value;
if (!val?.start) return props.placeholder;
const startFormatted = df.format(val.start.toDate(getLocalTimeZone()));
if (!val.end) return startFormatted;
const endFormatted = df.format(val.end.toDate(getLocalTimeZone()));
return `${startFormatted} - ${endFormatted}`;
});
function clearValue(event) {
event.stopPropagation();
emit("update:modelValue", { start: null, end: null });
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
:disabled="disabled"
:class="
cn(
'justify-start text-left font-normal',
!calendarValue?.start && 'text-muted-foreground',
buttonClass
)
"
>
<CalendarIcon class="mr-2 h-4 w-4 shrink-0" />
<span class="truncate flex-1">{{ displayText }}</span>
<span
v-if="clearable && hasValue && !disabled"
class="ml-2 shrink-0 opacity-50 hover:opacity-100 cursor-pointer"
@click.stop.prevent="clearValue"
>
<XIcon class="h-4 w-4" />
</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<RangeCalendar
v-model="calendarValue"
:locale="locale"
:number-of-months="numberOfMonths"
:min-value="minValue"
:max-value="maxValue"
initial-focus
@update:start-value="
(startDate) => {
if (calendarValue?.start?.toString() !== startDate?.toString()) {
calendarValue = { start: startDate, end: undefined };
}
}
"
/>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { useForwardPropsEmits } from "reka-ui";
import { DrawerRoot } from "vaul-vue";
const props = defineProps({
activeSnapPoint: { type: [Number, String, null], required: false },
closeThreshold: { type: Number, required: false },
shouldScaleBackground: { type: Boolean, required: false, default: true },
setBackgroundColorOnScale: { type: Boolean, required: false },
scrollLockTimeout: { type: Number, required: false },
fixed: { type: Boolean, required: false },
dismissible: { type: Boolean, required: false },
modal: { type: Boolean, required: false },
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
nested: { type: Boolean, required: false },
direction: { type: String, required: false },
noBodyStyles: { type: Boolean, required: false },
handleOnly: { type: Boolean, required: false },
preventScrollRestoration: { type: Boolean, required: false },
snapPoints: { type: Array, required: false },
fadeFromIndex: { type: null, required: false },
});
const emits = defineEmits([
"drag",
"release",
"close",
"update:open",
"update:activeSnapPoint",
"animationEnd",
]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DrawerRoot v-bind="forwarded">
<slot />
</DrawerRoot>
</template>
@@ -0,0 +1,44 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { useForwardPropsEmits } from "reka-ui";
import { DrawerContent, DrawerPortal } from "vaul-vue";
import { cn } from "@/lib/utils";
import DrawerOverlay from "./DrawerOverlay.vue";
const props = defineProps({
forceMount: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
"openAutoFocus",
"closeAutoFocus",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DrawerPortal>
<DrawerOverlay />
<DrawerContent
v-bind="forwardedProps"
:class="
cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
props.class,
)
"
>
<div class="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
<slot />
</DrawerContent>
</DrawerPortal>
</template>
@@ -0,0 +1,22 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DrawerDescription } from "vaul-vue";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DrawerDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</DrawerDescription>
</template>
@@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('mt-auto flex flex-col gap-2 p-4', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('grid gap-1.5 p-4 text-center sm:text-left', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DrawerOverlay } from "vaul-vue";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DrawerOverlay
v-bind="delegatedProps"
:class="cn('fixed inset-0 z-50 bg-black/80', props.class)"
/>
</template>
@@ -0,0 +1,24 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DrawerTitle } from "vaul-vue";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DrawerTitle
v-bind="delegatedProps"
:class="
cn('text-lg font-semibold leading-none tracking-tight', props.class)
"
>
<slot />
</DrawerTitle>
</template>
@@ -0,0 +1,8 @@
export { default as Drawer } from "./Drawer.vue";
export { default as DrawerContent } from "./DrawerContent.vue";
export { default as DrawerDescription } from "./DrawerDescription.vue";
export { default as DrawerFooter } from "./DrawerFooter.vue";
export { default as DrawerHeader } from "./DrawerHeader.vue";
export { default as DrawerOverlay } from "./DrawerOverlay.vue";
export { default as DrawerTitle } from "./DrawerTitle.vue";
export { DrawerClose, DrawerPortal, DrawerTrigger } from "vaul-vue";
@@ -26,7 +26,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent <DropdownMenuContent
v-bind="forwarded" v-bind="forwarded"
:class="cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)" :class="cn('z-50 min-w-32 overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md max-h-[var(--reka-dropdown-menu-content-available-height)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
> >
<slot /> <slot />
</DropdownMenuContent> </DropdownMenuContent>
@@ -0,0 +1,97 @@
import { ref, onMounted, onUnmounted } from "vue";
import { router } from "@inertiajs/vue3";
/**
* Composable for infinite scroll with Inertia v2.
*
* @param {Function} getProp - () => the current paginator object from Inertia props
* @param {string} propName - the prop key name to reload
* @param {string} pageParam - query string parameter name for page number
* @param {Function} getRouteUrl - () => current URL to reload
*/
export function useInfiniteList(getProp, propName, pageParam, getRouteUrl) {
const items = ref([]);
const currentPage = ref(1);
const lastPage = ref(1);
const isLoadingMore = ref(false);
const sentinelRef = ref(null);
let observer = null;
function syncFromProp() {
const prop = getProp();
if (!prop) return;
lastPage.value = prop.last_page ?? 1;
}
function appendFromProp() {
const prop = getProp();
if (!prop?.data) return;
// append only new items (avoid duplicates by id)
const existingIds = new Set(items.value.map((i) => i.id));
const newItems = prop.data.filter((i) => !existingIds.has(i.id));
items.value.push(...newItems);
}
function reset(initialProp) {
items.value = initialProp?.data ?? [];
currentPage.value = initialProp?.current_page ?? 1;
lastPage.value = initialProp?.last_page ?? 1;
}
function loadMore() {
if (isLoadingMore.value) return;
if (currentPage.value >= lastPage.value) return;
const nextPage = currentPage.value + 1;
isLoadingMore.value = true;
const params = new URLSearchParams(window.location.search);
params.set(pageParam, nextPage);
router.reload({
url: `${window.location.pathname}?${params.toString()}`,
only: [propName],
preserveScroll: true,
preserveState: true,
onSuccess: () => {
appendFromProp();
currentPage.value = nextPage;
isLoadingMore.value = false;
},
onError: () => {
isLoadingMore.value = false;
},
});
}
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ rootMargin: "200px" }
);
if (sentinelRef.value) {
observer.observe(sentinelRef.value);
}
});
onUnmounted(() => {
observer?.disconnect();
});
return {
items,
currentPage,
lastPage,
isLoadingMore,
sentinelRef,
reset,
syncFromProp,
appendFromProp,
loadMore,
};
}
-8
View File
@@ -15,7 +15,6 @@ import {
InboxIcon, InboxIcon,
AtSignIcon, AtSignIcon,
BookUserIcon, BookUserIcon,
MessageSquareIcon,
ArrowLeftIcon, ArrowLeftIcon,
} from "lucide-vue-next"; } from "lucide-vue-next";
import Dropdown from "@/Components/Dropdown.vue"; import Dropdown from "@/Components/Dropdown.vue";
@@ -211,13 +210,6 @@ const navGroups = computed(() => [
icon: Settings2Icon, icon: Settings2Icon,
active: ["admin.sms-profiles.index"], active: ["admin.sms-profiles.index"],
}, },
{
key: "admin.packages.index",
label: "SMS paketi",
route: "admin.packages.index",
icon: MessageSquareIcon,
active: ["admin.packages.index", "admin.packages.show"],
},
], ],
}, },
]); ]);
+33 -1
View File
@@ -26,6 +26,9 @@ import { SettingsIcon } from "lucide-vue-next";
import { ShieldUserIcon } from "lucide-vue-next"; import { ShieldUserIcon } from "lucide-vue-next";
import { SmartphoneIcon } from "lucide-vue-next"; import { SmartphoneIcon } from "lucide-vue-next";
import { TabletSmartphoneIcon } from "lucide-vue-next"; import { TabletSmartphoneIcon } from "lucide-vue-next";
import { PhoneCallIcon } from "lucide-vue-next";
import { PackageIcon } from "lucide-vue-next";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({ const props = defineProps({
title: String, title: String,
@@ -157,6 +160,13 @@ const rawMenuGroups = [
routeName: "segments.index", routeName: "segments.index",
active: ["segments.index"], active: ["segments.index"],
}, },
{
key: "call-laters",
icon: PhoneCallIcon,
title: "Pokliči kasneje",
routeName: "callLaters.index",
active: ["callLaters.index"],
},
], ],
}, },
{ {
@@ -212,6 +222,13 @@ const rawMenuGroups = [
routeName: "settings", routeName: "settings",
active: ["settings", "settings.*"], active: ["settings", "settings.*"],
}, },
{
key: "packages",
icon: PackageIcon,
title: "Paketno pošiljanje",
routeName: "packages.index",
active: ["packages.index", "packages.show", "packages.create"],
},
// Admin panel (roles & permissions management) // Admin panel (roles & permissions management)
// Only shown if current user has admin role or manage-settings permission. // Only shown if current user has admin role or manage-settings permission.
// We'll filter it out below if not authorized. // We'll filter it out below if not authorized.
@@ -268,6 +285,14 @@ function isActive(patterns) {
return false; return false;
} }
} }
function getBadge(item) {
if (item.key === "call-laters") {
return page.props.callLaterCount || 0;
}
return 0;
}
</script> </script>
<template> <template>
@@ -341,11 +366,18 @@ function isActive(patterns) {
<!-- Title --> <!-- Title -->
<span <span
v-if="!sidebarCollapsed" v-if="!sidebarCollapsed"
class="truncate transition-opacity" class="flex-1 truncate transition-opacity"
:class="{ 'font-medium': isActive(item.active) }" :class="{ 'font-medium': isActive(item.active) }"
> >
{{ item.title }} {{ item.title }}
</span> </span>
<Badge
v-if="!sidebarCollapsed && getBadge(item) > 0"
variant="destructive"
class="ml-auto shrink-0 px-1.5 py-0.5 text-xs font-mono text-amber-50"
>
{{ getBadge(item) }}
</Badge>
</Link> </Link>
</li> </li>
</ul> </ul>
+6 -3
View File
@@ -224,7 +224,7 @@ const closeSearch = () => (searchOpen.value = false);
<div class="flex-1 flex flex-col min-w-0"> <div class="flex-1 flex flex-col min-w-0">
<!-- Top bar --> <!-- Top bar -->
<div <div
class="h-16 bg-white border-b border-gray-200 px-4 flex items-center justify-between sticky top-0 z-30 backdrop-blur-sm bg-white/95 shadow-sm" class="h-16 border-b border-gray-200 px-4 flex items-center justify-between sticky top-0 z-30 backdrop-blur-sm bg-white/95 shadow-sm"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- Sidebar toggle --> <!-- Sidebar toggle -->
@@ -308,7 +308,10 @@ const closeSearch = () => (searchOpen.value = false);
</div> </div>
<!-- Page Heading --> <!-- Page Heading -->
<header v-if="$slots.header" class="bg-white border-b border-gray-200 shadow-sm"> <header
v-if="$slots.header"
class="sticky top-16 z-20 bg-white border-b border-gray-200 shadow-sm dark:bg-gray-900 dark:border-gray-700"
>
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2"> <div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
<Breadcrumbs <Breadcrumbs
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length" v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
@@ -319,7 +322,7 @@ const closeSearch = () => (searchOpen.value = false);
</header> </header>
<!-- Page Content --> <!-- Page Content -->
<main class="flex-1 p-4 sm:p-6"> <main class="flex-1 lg:p-4">
<slot /> <slot />
</main> </main>
</div> </div>
@@ -64,6 +64,7 @@ import "quill/dist/quill.snow.css";
const props = defineProps({ const props = defineProps({
template: { type: Object, default: null }, template: { type: Object, default: null },
actions: { type: Array, default: () => [] },
}); });
const form = useForm({ const form = useForm({
@@ -75,6 +76,9 @@ const form = useForm({
entity_types: props.template?.entity_types ?? ["client", "contract"], entity_types: props.template?.entity_types ?? ["client", "contract"],
allow_attachments: props.template?.allow_attachments ?? false, allow_attachments: props.template?.allow_attachments ?? false,
active: props.template?.active ?? true, active: props.template?.active ?? true,
client: props.template?.client ?? false,
action_id: props.template?.action_id ?? null,
decision_id: props.template?.decision_id ?? null,
}); });
const preview = ref({ subject: "", html: "", text: "" }); const preview = ref({ subject: "", html: "", text: "" });
@@ -732,7 +736,8 @@ const placeholderGroups = computed(() => {
"contract.id", "contract.id",
"contract.uuid", "contract.uuid",
"contract.reference", "contract.reference",
"contract.amount", "contract.account.balance_amount",
"contract.account.initial_amount",
"contract.meta.some_key", "contract.meta.some_key",
]); ]);
} }
@@ -747,6 +752,13 @@ const placeholderGroups = computed(() => {
]); ]);
// 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"]);
// Profile signature tokens (resolved from the active mail profile at send time)
add("profile", "Profil / Podpis", [
"profile.signature.ime",
"profile.signature.naziv",
"profile.signature.telefon",
"profile.signature.email",
]);
return groups; return groups;
}); });
@@ -1028,6 +1040,49 @@ watch(
/> />
<Label for="active" class="font-normal cursor-pointer">Aktivno</Label> <Label for="active" class="font-normal cursor-pointer">Aktivno</Label>
</div> </div>
<div class="flex items-center gap-2">
<Switch
id="client"
:default-value="form.client"
@update:model-value="(val) => (form.client = val)"
/>
<Label for="client" class="font-normal cursor-pointer">Samo za stranke</Label>
</div>
</div>
<!-- Activity after send: action + decision -->
<div>
<Label class="mb-2 block">Aktivnost po pošiljanju</Label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="action_id">Akcija</Label>
<Select v-model="form.action_id" @update:model-value="(val) => { form.action_id = val; form.decision_id = null; }">
<SelectTrigger id="action_id">
<SelectValue placeholder="Brez" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Brez</SelectItem>
<SelectItem v-for="a in props.actions" :key="a.id" :value="a.id">{{ a.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="decision_id">Odločitev</Label>
<Select v-model="form.decision_id" :disabled="!form.action_id">
<SelectTrigger id="decision_id">
<SelectValue placeholder="Brez" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Brez</SelectItem>
<SelectItem
v-for="d in props.actions?.find((x) => x.id === form.action_id)?.decisions || []"
:key="d.id"
:value="d.id"
>{{ d.name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div> </div>
<Separator /> <Separator />
@@ -1223,6 +1278,25 @@ watch(
</Button> </Button>
</div> </div>
</div> </div>
<!-- Special tokens -->
<div class="space-y-2">
<div class="text-sm font-medium text-muted-foreground">Posebni žetoni</div>
<p class="text-xs text-muted-foreground">
<code class="font-mono">&#123;&#123; body_text &#125;&#125;</code> — pri pošiljanju ga nadomesti besedilo, ki ga vnese pošiljatelj.
</p>
<div class="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
@click="insertPlaceholder('body_text')"
class="font-mono text-xs"
>
<PlusCircleIcon class="h-3 w-3 mr-1" />
body_text
</Button>
</div>
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
-6
View File
@@ -107,12 +107,6 @@ const cards = [
route: "admin.sms-logs.index", route: "admin.sms-logs.index",
icon: InboxIcon, icon: InboxIcon,
}, },
{
title: "SMS paketi",
description: "Kreiranje in pošiljanje serijskih SMS paketov",
route: "admin.packages.index",
icon: MessageSquareIcon,
},
], ],
}, },
]; ];

Some files were not shown because too many files have changed in this diff Show More