32 Commits

Author SHA1 Message Date
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
131 changed files with 9720 additions and 2117 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);
@@ -3,16 +3,18 @@
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\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
@@ -22,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()
@@ -70,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,
@@ -79,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);
@@ -213,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();
@@ -261,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();
@@ -287,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');
@@ -446,6 +528,7 @@ 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'),
]; ];
@@ -542,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.');
}
}
+247 -45
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',
@@ -326,14 +355,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
// Determine which contracts to process // Determine which contracts to process
$contractIds = []; $contractIds = [];
if ($createForAll && !empty($contractUuids)) { if ($createForAll && ! empty($contractUuids)) {
// Get all contract IDs from the provided UUIDs // Get all contract IDs from the provided UUIDs
$contracts = Contract::withTrashed() $contracts = Contract::withTrashed()
->whereIn('uuid', $contractUuids) ->whereIn('uuid', $contractUuids)
->where('client_case_id', $clientCase->id) ->where('client_case_id', $clientCase->id)
->get(); ->get();
$contractIds = $contracts->pluck('id')->toArray(); $contractIds = $contracts->pluck('id')->toArray();
} elseif (!empty($contractUuids) && isset($contractUuids[0])) { } elseif (! empty($contractUuids) && isset($contractUuids[0])) {
// Single contract mode // Single contract mode
$contract = Contract::withTrashed() $contract = Contract::withTrashed()
->where('uuid', $contractUuids[0]) ->where('uuid', $contractUuids[0])
@@ -342,7 +371,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
if ($contract) { if ($contract) {
$contractIds = [$contract->id]; $contractIds = [$contract->id];
} }
} elseif (!empty($attributes['contract_uuid'])) { } elseif (! empty($attributes['contract_uuid'])) {
// Legacy single contract_uuid support // Legacy single contract_uuid support
$contract = Contract::withTrashed() $contract = Contract::withTrashed()
->where('uuid', $attributes['contract_uuid']) ->where('uuid', $attributes['contract_uuid'])
@@ -360,7 +389,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
$createdActivities = []; $createdActivities = [];
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true); $sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
// Disable auto mail if creating activities for multiple contracts // Disable auto mail if creating activities for multiple contracts
if ($sendFlag && count($contractIds) > 1) { if ($sendFlag && count($contractIds) > 1) {
$sendFlag = false; $sendFlag = false;
@@ -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'],
@@ -417,29 +447,29 @@ public function storeActivity(ClientCase $clientCase, Request $request)
->whereIn('id', $attachmentIds) ->whereIn('id', $attachmentIds)
->pluck('id'); ->pluck('id');
$validAttachmentIds = Document::query() $validAttachmentIds = Document::query()
->where('documentable_type', Contract::class) ->where('documentable_type', Contract::class)
->where('documentable_id', $contractId) ->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds) ->whereIn('id', $attachmentIds)
->pluck('id'); ->pluck('id');
}
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
'attachment_ids' => $validAttachmentIds->all(),
]);
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
// If template requires contract and user attempted to send, surface a validation message
logger()->warning('Email not queued: required contract is missing for the selected template.');
}
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
}
} catch (\Throwable $e) {
// Do not fail activity creation due to mailing issues
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
} }
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
'attachment_ids' => $validAttachmentIds->all(),
]);
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
// If template requires contract and user attempted to send, surface a validation message
logger()->warning('Email not queued: required contract is missing for the selected template.');
}
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
}
} catch (\Throwable $e) {
// Do not fail activity creation due to mailing issues
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
}
} }
$activityCount = count($createdActivities); $activityCount = count($createdActivities);
$successMessage = $activityCount > 1 $successMessage = $activityCount > 1
? "Successfully created {$activityCount} activities!" ? "Successfully created {$activityCount} activities!"
: 'Successfully created activity!'; : 'Successfully created activity!';
@@ -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',
]); ]);
@@ -1115,13 +1161,14 @@ public function archiveBatch(Request $request)
foreach ($validated['contracts'] as $contractUuid) { foreach ($validated['contracts'] as $contractUuid) {
try { try {
$contract = Contract::where('uuid', $contractUuid)->firstOrFail(); $contract = Contract::where('uuid', $contractUuid)->firstOrFail();
// 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;
} }
$clientCase = $contract->clientCase; $clientCase = $contract->clientCase;
$context = [ $context = [
@@ -1208,8 +1255,8 @@ 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,
'details' => $errors, 'details' => $errors,
@@ -1219,7 +1266,7 @@ public function archiveBatch(Request $request)
$message = $reactivate $message = $reactivate
? "Successfully reactivated $successCount contracts" ? "Successfully reactivated $successCount contracts"
: "Successfully archived $successCount contracts"; : "Successfully archived $successCount contracts";
if ($skippedCount > 0) { if ($skippedCount > 0) {
$message .= " ($skippedCount already archived)"; $message .= " ($skippedCount already archived)";
} }
@@ -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 = [];
+9 -13
View File
@@ -27,7 +27,7 @@ public function index(Client $client, Request $request)
->where('person.full_name', 'ilike', '%'.$search.'%') ->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id'); ->groupBy('clients.id');
}) })
//->where('clients.active', 1) // ->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries // Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id') ->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) { ->leftJoin('contracts', function ($join) {
@@ -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(),
@@ -175,7 +171,7 @@ public function exportContracts(ExportClientContractsRequest $request, Client $c
{ {
$data = $request->validated(); $data = $request->validated();
$columns = array_values(array_unique($data['columns'])); $columns = array_values(array_unique($data['columns']));
$from = $data['from'] ?? null; $from = $data['from'] ?? null;
$to = $data['to'] ?? null; $to = $data['to'] ?? null;
$search = $data['search'] ?? null; $search = $data['search'] ?? null;
@@ -236,7 +232,7 @@ private function buildExportFilename(Client $client): string
{ {
$datePrefix = now()->format('dmy'); $datePrefix = now()->format('dmy');
$clientName = $this->slugify($client->person?->full_name ?? 'stranka'); $clientName = $this->slugify($client->person?->full_name ?? 'stranka');
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName); return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
} }
@@ -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.');
}
}
+16 -11
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;
@@ -47,9 +46,9 @@ public function __invoke(SmsService $sms): Response
return Account::whereHas('contract', function ($q) { return Account::whereHas('contract', function ($q) {
$q->whereNull('deleted_at'); $q->whereNull('deleted_at');
}) })
->whereNotNull('promise_date') ->whereNotNull('promise_date')
->whereDate('promise_date', '>=', $today) ->whereDate('promise_date', '>=', $today)
->count(); ->count();
}); });
// Activities (limit 10) - cached // Activities (limit 10) - cached
@@ -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
@@ -64,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(),
@@ -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.');
}
}
+18 -9
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)
@@ -72,7 +71,7 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$address->update($attributes); $address->update($attributes);
return back()->with('success', 'Address updated')->with('flash_method', 'PUT'); return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
} }
public function deleteAddress(Person $person, int $address_id, Request $request) public function deleteAddress(Person $person, int $address_id, 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,8 +248,7 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id); $trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete(); $trr->delete();
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE'); return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
} }
} }
+30 -31
View File
@@ -10,42 +10,40 @@
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 = [
'contract' => function ($q) {
$q->with([
'type:id,name',
'account',
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
];
$baseQuery = FieldJob::query()
->where('assigned_user_id', $userId) ->where('assigned_user_id', $userId)
->whereNull('completed_at') ->whereNull('completed_at')
->whereNull('cancelled_at') ->whereNull('cancelled_at')
->with([ ->with($eagerLoad);
'contract' => function ($q) {
$q->with([
'type:id,name',
'account',
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
])
->orderByDesc('assigned_at');
// 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' => [
+43 -6
View File
@@ -43,7 +43,7 @@ public function show(string $slug, Request $request)
$inputs = $this->buildInputsArray($report); $inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request); $filters = $this->validateFilters($inputs, $request);
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]); \Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
$perPage = (int) ($request->integer('per_page') ?: 25); $perPage = (int) ($request->integer('per_page') ?: 25);
$query = $this->queryBuilder->build($report, $filters); $query = $this->queryBuilder->build($report, $filters);
$paginator = $query->paginate($perPage); $paginator = $query->paginate($perPage);
@@ -279,16 +279,51 @@ public function clients(Request $request)
$clients = \App\Models\Client::query() $clients = \App\Models\Client::query()
->with('person:id,full_name') ->with('person:id,full_name')
->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();
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'],
}; };
} }
@@ -319,7 +356,7 @@ protected function validateFilters(array $inputs, Request $request): array
*/ */
protected function buildInputsArray(Report $report): array protected function buildInputsArray(Report $report): array
{ {
return $report->filters->map(fn($filter) => [ return $report->filters->map(fn ($filter) => [
'key' => $filter->key, 'key' => $filter->key,
'type' => $filter->type, 'type' => $filter->type,
'label' => $filter->label, 'label' => $filter->label,
@@ -336,7 +373,7 @@ protected function buildColumnsArray(Report $report): array
{ {
return $report->columns return $report->columns
->where('visible', true) ->where('visible', true)
->map(fn($col) => [ ->map(fn ($col) => [
'key' => $col->key, 'key' => $col->key,
'label' => $col->label, 'label' => $col->label,
]) ])
+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",
+1 -1
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",
+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="si-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">
<img <div
:src="props.src" ref="containerRef"
:alt="props.title" class="relative h-full overflow-hidden select-none"
class="max-w-full max-h-full mx-auto object-contain" :class="imageCursorClass"
/> @mousedown="handleMouseDown"
@wheel.prevent="handleWheel"
>
<img
ref="imageRef"
:src="props.src"
:alt="props.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"
/>
<!-- 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 -->
@@ -91,7 +91,7 @@ function maskIban(iban) {
<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" /> <FontAwesomeIcon :icon="faLocationDot" 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" /> <FontAwesomeIcon :icon="faPhone" class="w-4 h-4 mr-1" />
@@ -109,11 +109,11 @@ 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" /> <FontAwesomeIcon :icon="faEnvelope" 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" /> <FontAwesomeIcon :icon="faLandmark" class="w-4 h-4 mr-1" />
{{ maskIban(bankIban) }} {{ bankIban }}
</span> </span>
</div> </div>
@@ -129,7 +129,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>
@@ -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,7 +46,17 @@ const handleDelete = (id, label) => emit("delete", id, label);
</span> </span>
</div> </div>
<div v-if="edit"> <div v-if="edit">
<DropdownMenu> <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>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" title="Možnosti"> <Button variant="ghost" size="icon" title="Možnosti">
<EllipsisVertical /> <EllipsisVertical />
@@ -66,11 +78,28 @@ const handleDelete = (id, label) => emit("delete", id, label);
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</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,11 +453,57 @@ const open = computed({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form @submit.prevent="onSubmit" class="space-y-4"> <ScrollArea class="max-h-[65vh] pr-1">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <form @submit.prevent="onSubmit" class="space-y-4 pr-3">
<FormField v-slot="{ value, handleChange }" name="profile_id"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField v-slot="{ value, handleChange }" name="profile_id">
<FormItem>
<FormLabel>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 pageSmsProfiles" :key="p.id" :value="p.id">
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="sender_id">
<FormItem>
<FormLabel>Pošiljatelj</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="s in sendersForSelectedProfile"
:key="s.id"
:value="s.id"
>
{{ s.name || s.phone || "Sender #" + s.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
<FormItem> <FormItem>
<FormLabel>Profil</FormLabel> <FormLabel>Pogodba</FormLabel>
<Select :model-value="value" @update:model-value="handleChange"> <Select :model-value="value" @update:model-value="handleChange">
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@@ -465,18 +512,22 @@ const open = computed({
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem :value="null"></SelectItem> <SelectItem :value="null"></SelectItem>
<SelectItem v-for="p in pageSmsProfiles" :key="p.id" :value="p.id"> <SelectItem v-for="c in contractsForCase" :key="c.uuid" :value="c.uuid">
{{ p.name || "Profil #" + p.id }} {{ c.reference || c.uuid }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in
{account.*} mest.
</p>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ value, handleChange }" name="sender_id"> <FormField v-slot="{ value, handleChange }" name="template_id">
<FormItem> <FormItem>
<FormLabel>Pošiljatelj</FormLabel> <FormLabel>Predloga</FormLabel>
<Select :model-value="value" @update:model-value="handleChange"> <Select :model-value="value" @update:model-value="handleChange">
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@@ -485,125 +536,77 @@ const open = computed({
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem :value="null"></SelectItem> <SelectItem :value="null"></SelectItem>
<SelectItem <SelectItem v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
v-for="s in sendersForSelectedProfile" {{ t.name || "Predloga #" + t.id }}
:key="s.id"
:value="s.id"
>
{{ s.name || s.phone || "Sender #" + s.id }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
</div>
<FormField v-slot="{ value, handleChange }" name="contract_uuid"> <FormField v-slot="{ componentField }" name="message">
<FormItem> <FormItem>
<FormLabel>Pogodba</FormLabel> <FormLabel>Vsebina sporočila</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl> <FormControl>
<SelectTrigger> <Textarea
<SelectValue placeholder="" /> rows="4"
</SelectTrigger> placeholder="Vpišite SMS vsebino..."
v-bind="componentField"
/>
</FormControl> </FormControl>
<SelectContent> <FormMessage />
<SelectItem :value="null"></SelectItem> </FormItem>
<SelectItem v-for="c in contractsForCase" :key="c.uuid" :value="c.uuid"> </FormField>
{{ c.reference || c.uuid }}
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in
{account.*} mest.
</p>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="template_id"> <!-- Live counters -->
<FormItem> <div class="text-xs text-gray-600 flex flex-col gap-1">
<FormLabel>Predloga</FormLabel> <div>
<Select :model-value="value" @update:model-value="handleChange"> <span class="font-medium">Znakov:</span>
<FormControl> <span class="font-mono">{{ charCount }}</span>
<SelectTrigger> <span class="mx-2">|</span>
<SelectValue placeholder="—" /> <span class="font-medium">Kodiranje:</span>
</SelectTrigger> <span>{{ smsEncoding }}</span>
</FormControl> <span class="mx-2">|</span>
<SelectContent> <span class="font-medium">Deli SMS:</span>
<SelectItem :value="null"></SelectItem> <span class="font-mono">{{ segments }}</span>
<SelectItem v-for="t in pageSmsTemplates" :key="t.id" :value="t.id"> <span class="mx-2">|</span>
{{ t.name || "Predloga #" + t.id }} <span class="font-medium">Krediti:</span>
</SelectItem> <span class="font-mono">{{ creditsNeeded }}</span>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>Vsebina sporočila</FormLabel>
<FormControl>
<Textarea
rows="4"
placeholder="Vpišite SMS vsebino..."
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Live counters -->
<div class="text-xs text-gray-600 flex flex-col gap-1">
<div>
<span class="font-medium">Znakov:</span>
<span class="font-mono">{{ charCount }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Kodiranje:</span>
<span>{{ smsEncoding }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Deli SMS:</span>
<span class="font-mono">{{ segments }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Krediti:</span>
<span class="font-mono">{{ creditsNeeded }}</span>
</div>
<div>
<span class="font-medium">Omejitev:</span>
<span class="font-mono">{{ maxAllowed }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Preostanek:</span>
<span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">
{{ remaining }}
</span>
</div>
<p class="text-[11px] text-gray-500 leading-snug">
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake, ki
ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
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
sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in )
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM7) oziroma
320 (UCS2) znakov.
</p>
</div>
<FormField v-slot="{ value, handleChange }" name="delivery_report">
<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>Zahtevaj poročilo o dostavi</FormLabel>
</div> </div>
</FormItem> <div>
</FormField> <span class="font-medium">Omejitev:</span>
</form> <span class="font-mono">{{ maxAllowed }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Preostanek:</span>
<span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">
{{ remaining }}
</span>
</div>
<p class="text-[11px] text-gray-500 leading-snug">
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake,
ki ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
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
sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in )
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM7) oziroma
320 (UCS2) znakov.
</p>
</div>
<FormField v-slot="{ value, handleChange }" name="delivery_report">
<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>Zahtevaj poročilo o dostavi</FormLabel>
</div>
</FormItem>
</FormField>
</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
@@ -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,
},
], ],
}, },
]; ];
+144 -6
View File
@@ -9,6 +9,7 @@ import {
PencilIcon, PencilIcon,
SendIcon, SendIcon,
MoreVerticalIcon, MoreVerticalIcon,
Trash2Icon,
} from "lucide-vue-next"; } from "lucide-vue-next";
import { import {
Card, Card,
@@ -62,6 +63,32 @@ const createOpen = ref(false); // create modal
const editOpen = ref(false); // edit modal const editOpen = ref(false); // edit modal
const editTarget = ref(null); // profile being edited const editTarget = ref(null); // profile being edited
// Signature items — array of {key, value} pairs edited in the dialog
const signatureItems = ref([{ key: "", value: "" }]);
function addSignatureItem() {
signatureItems.value.push({ key: "", value: "" });
}
function removeSignatureItem(index) {
signatureItems.value.splice(index, 1);
if (signatureItems.value.length === 0)
signatureItems.value.push({ key: "", value: "" });
}
function signatureToObject() {
const obj = {};
signatureItems.value.forEach(({ key, value }) => {
const k = (key || "").trim();
if (k) obj[k] = value ?? "";
});
return Object.keys(obj).length ? obj : null;
}
function signatureFromObject(sig) {
const entries = Object.entries(sig || {});
return entries.length
? entries.map(([key, value]) => ({ key, value }))
: [{ key: "", value: "" }];
}
const form = useForm({ const form = useForm({
name: "", name: "",
host: "", host: "",
@@ -72,10 +99,12 @@ const form = useForm({
from_address: "", from_address: "",
from_name: "", from_name: "",
priority: 10, priority: 10,
auto_mailer: false,
}); });
function openCreate() { function openCreate() {
form.reset(); form.reset();
signatureItems.value = [{ key: "", value: "" }];
createOpen.value = true; createOpen.value = true;
editTarget.value = null; editTarget.value = null;
} }
@@ -92,7 +121,9 @@ function openEdit(p) {
form.from_address = p.from_address || ""; form.from_address = p.from_address || "";
form.from_name = p.from_name || ""; form.from_name = p.from_name || "";
form.priority = p.priority ?? 10; form.priority = p.priority ?? 10;
form.auto_mailer = p.auto_mailer ?? false;
editTarget.value = p; editTarget.value = p;
signatureItems.value = signatureFromObject(p.signature);
editOpen.value = true; editOpen.value = true;
} }
@@ -102,12 +133,14 @@ function closeCreate() {
} }
function submitCreate() { function submitCreate() {
form.post(route("admin.mail-profiles.store"), { form
preserveScroll: true, .transform((data) => ({ ...data, signature: signatureToObject() }))
onSuccess: () => { .post(route("admin.mail-profiles.store"), {
createOpen.value = false; preserveScroll: true,
}, onSuccess: () => {
}); createOpen.value = false;
},
});
} }
function closeEdit() { function closeEdit() {
@@ -128,6 +161,8 @@ function submitEdit() {
from_address: form.from_address, from_address: form.from_address,
from_name: form.from_name || null, from_name: form.from_name || null,
priority: form.priority, priority: form.priority,
auto_mailer: form.auto_mailer,
signature: signatureToObject(),
}; };
if (form.password && form.password.trim() !== "") { if (form.password && form.password.trim() !== "") {
payload.password = form.password.trim(); payload.password = form.password.trim();
@@ -149,6 +184,12 @@ function toggleActive(p) {
.then(() => window.location.reload()); .then(() => window.location.reload());
} }
function toggleAutoMailer(p) {
window.axios
.post(route("admin.mail-profiles.toggle-auto-mailer", p.id))
.then(() => window.location.reload());
}
function testConnection(p) { function testConnection(p) {
window.axios window.axios
.post(route("admin.mail-profiles.test", p.id)) .post(route("admin.mail-profiles.test", p.id))
@@ -206,6 +247,7 @@ const statusClass = (p) => {
<TableHead class="text-center">Port</TableHead> <TableHead class="text-center">Port</TableHead>
<TableHead class="text-center">Enc</TableHead> <TableHead class="text-center">Enc</TableHead>
<TableHead class="text-center">Aktivno</TableHead> <TableHead class="text-center">Aktivno</TableHead>
<TableHead class="text-center">Auto-mailer</TableHead>
<TableHead class="text-center">Status</TableHead> <TableHead class="text-center">Status</TableHead>
<TableHead>Zadnji uspeh</TableHead> <TableHead>Zadnji uspeh</TableHead>
<TableHead>Napaka</TableHead> <TableHead>Napaka</TableHead>
@@ -229,6 +271,12 @@ const statusClass = (p) => {
@update:model-value="() => toggleActive(p)" @update:model-value="() => toggleActive(p)"
/> />
</TableCell> </TableCell>
<TableCell class="text-center">
<Switch
:default-value="p.auto_mailer"
@update:model-value="() => toggleAutoMailer(p)"
/>
</TableCell>
<TableCell class="text-center"> <TableCell class="text-center">
<Badge <Badge
v-if="p.test_status === 'success'" v-if="p.test_status === 'success'"
@@ -350,6 +398,51 @@ const statusClass = (p) => {
<Label for="create-priority">Prioriteta</Label> <Label for="create-priority">Prioriteta</Label>
<Input id="create-priority" v-model.number="form.priority" type="number" /> <Input id="create-priority" v-model.number="form.priority" type="number" />
</div> </div>
<div class="flex items-center gap-3">
<Switch
id="create-auto-mailer"
:model-value="form.auto_mailer"
@update:model-value="(val) => (form.auto_mailer = val)"
/>
<Label for="create-auto-mailer">Auto-mailer</Label>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Podpis (signature)</Label>
<Button type="button" size="sm" variant="outline" @click="addSignatureItem">
<PlusIcon class="h-3 w-3 mr-1" />
Dodaj vrstico
</Button>
</div>
<p class="text-xs text-muted-foreground">
Vrednosti so dostopne v predlogah kot
<code class="font-mono" v-pre>{{ profile.signature.ključ }}</code
>.
</p>
<div class="space-y-2">
<div
v-for="(item, i) in signatureItems"
:key="i"
class="flex gap-2 items-start"
>
<Input
v-model="item.key"
placeholder="Ključ (npr. ime)"
class="w-36 shrink-0 font-mono text-xs"
/>
<Input v-model="item.value" placeholder="Vrednost" class="flex-1" />
<Button
type="button"
variant="ghost"
size="sm"
@click="removeSignatureItem(i)"
>
<Trash2Icon class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div> </div>
</form> </form>
<DialogFooter> <DialogFooter>
@@ -418,6 +511,51 @@ const statusClass = (p) => {
<Label for="edit-priority">Prioriteta</Label> <Label for="edit-priority">Prioriteta</Label>
<Input id="edit-priority" v-model.number="form.priority" type="number" /> <Input id="edit-priority" v-model.number="form.priority" type="number" />
</div> </div>
<div class="flex items-center gap-3">
<Switch
id="edit-auto-mailer"
:model-value="form.auto_mailer"
@update:model-value="(val) => (form.auto_mailer = val)"
/>
<Label for="edit-auto-mailer">Auto-mailer</Label>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Podpis (signature)</Label>
<Button type="button" size="sm" variant="outline" @click="addSignatureItem">
<PlusIcon class="h-3 w-3 mr-1" />
Dodaj vrstico
</Button>
</div>
<p class="text-xs text-muted-foreground">
Vrednosti so dostopne v predlogah kot
<code class="font-mono" v-pre>{{ profile.signature.ključ }}</code
>.
</p>
<div class="space-y-2">
<div
v-for="(item, i) in signatureItems"
:key="i"
class="flex gap-2 items-start"
>
<Input
v-model="item.key"
placeholder="Ključ (npr. ime)"
class="w-36 shrink-0 font-mono text-xs"
/>
<Input v-model="item.value" placeholder="Vrednost" class="flex-1" />
<Button
type="button"
variant="ghost"
size="sm"
@click="removeSignatureItem(i)"
>
<Trash2Icon class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div> </div>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Pusti geslo prazno, če želiš obdržati obstoječe. Pusti geslo prazno, če želiš obdržati obstoječe.
+23 -11
View File
@@ -2,7 +2,13 @@
import AdminLayout from "@/Layouts/AdminLayout.vue"; import AdminLayout from "@/Layouts/AdminLayout.vue";
import { useForm, Link } from "@inertiajs/vue3"; import { useForm, Link } from "@inertiajs/vue3";
import { KeyRoundIcon, ArrowLeftIcon, SaveIcon } from "lucide-vue-next"; import { KeyRoundIcon, ArrowLeftIcon, SaveIcon } from "lucide-vue-next";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label"; import { Label } from "@/Components/ui/label";
@@ -36,12 +42,16 @@ function submit() {
<CardHeader> <CardHeader>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"> <div
class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"
>
<KeyRoundIcon class="h-5 w-5" /> <KeyRoundIcon class="h-5 w-5" />
</div> </div>
<div> <div>
<CardTitle>Uredi dovoljenje</CardTitle> <CardTitle>Uredi dovoljenje</CardTitle>
<CardDescription>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription> <CardDescription
>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription
>
</div> </div>
</div> </div>
<Button variant="ghost" size="sm" as-child> <Button variant="ghost" size="sm" as-child>
@@ -53,7 +63,6 @@ function submit() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form @submit.prevent="submit" class="space-y-6"> <form @submit.prevent="submit" class="space-y-6">
<div class="grid sm:grid-cols-2 gap-6"> <div class="grid sm:grid-cols-2 gap-6">
<div class="space-y-2"> <div class="space-y-2">
@@ -86,16 +95,19 @@ function submit() {
class="flex items-center gap-2 text-sm cursor-pointer" class="flex items-center gap-2 text-sm cursor-pointer"
> >
<Checkbox <Checkbox
:value="r.id" :default-value="form.roles.includes(r.id)"
:checked="form.roles.includes(r.id)" @update:model-value="
@update:checked="(checked) => { (checked) => {
if (checked) form.roles.push(r.id) if (checked) form.roles.push(r.id);
else form.roles = form.roles.filter(id => id !== r.id) else form.roles = form.roles.filter((id) => id !== r.id);
}" }
"
/> />
<span <span
><span class="font-medium">{{ r.name }}</span> ><span class="font-medium">{{ r.name }}</span>
<span class="text-xs text-muted-foreground">({{ r.slug }})</span></span <span class="text-xs text-muted-foreground"
>({{ r.slug }})</span
></span
> >
</label> </label>
</div> </div>
+32 -1
View File
@@ -2,7 +2,7 @@
import AdminLayout from "@/Layouts/AdminLayout.vue"; import AdminLayout from "@/Layouts/AdminLayout.vue";
import { useForm, Link, router } from "@inertiajs/vue3"; import { useForm, Link, router } from "@inertiajs/vue3";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { SearchIcon, SaveIcon, UserPlusIcon } from "lucide-vue-next"; import { SearchIcon, SaveIcon, UserPlusIcon, Link2Icon } from "lucide-vue-next";
import { import {
Card, Card,
CardContent, CardContent,
@@ -48,6 +48,13 @@ const forms = Object.fromEntries(
]) ])
); );
const settingsForms = Object.fromEntries(
props.users.map((u) => [
u.id,
useForm({ login_redirect: u.login_redirect ?? "" }),
])
);
function toggle(userId, roleId) { function toggle(userId, roleId) {
const form = forms[userId]; const form = forms[userId];
const exists = form.roles.includes(roleId); const exists = form.roles.includes(roleId);
@@ -140,6 +147,12 @@ function toggleUserActive(userId) {
} }
); );
} }
function submitSettings(userId) {
settingsForms[userId].patch(route("admin.users.settings", { user: userId }), {
preserveScroll: true,
});
}
</script> </script>
<template> <template>
@@ -254,6 +267,24 @@ function toggleUserActive(userId) {
<div class="text-xs text-muted-foreground font-mono"> <div class="text-xs text-muted-foreground font-mono">
{{ user.email }} {{ user.email }}
</div> </div>
<div class="flex items-center gap-1 mt-1.5">
<Link2Icon class="h-3 w-3 text-muted-foreground shrink-0" />
<Input
v-model="settingsForms[user.id].login_redirect"
type="text"
placeholder="/dashboard"
class="h-6 text-xs px-1.5 w-36"
/>
<Button
@click="submitSettings(user.id)"
:disabled="settingsForms[user.id].processing"
size="sm"
variant="ghost"
class="h-6 px-2 text-xs"
>
<SaveIcon class="h-3 w-3" />
</Button>
</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
+288
View File
@@ -0,0 +1,288 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { computed, ref } from "vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import InputLabel from "@/Components/InputLabel.vue";
import Pagination from "@/Components/Pagination.vue";
import {
PhoneCallIcon,
CheckIcon,
Filter,
ExternalLinkIcon,
MoreHorizontalIcon,
} from "lucide-vue-next";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({
callLaters: Object,
filters: Object,
});
const search = ref(props.filters?.search || "");
const dateFrom = ref(props.filters?.date_from || "");
const dateTo = ref(props.filters?.date_to || "");
const filterPopoverOpen = ref(false);
const appliedFilterCount = computed(() => {
let count = 0;
if (search.value?.trim()) count += 1;
if (dateFrom.value) count += 1;
if (dateTo.value) count += 1;
return count;
});
function applyFilters() {
filterPopoverOpen.value = false;
const params = {};
if (search.value?.trim()) {
params.search = search.value.trim();
}
if (dateFrom.value) {
params.date_from = dateFrom.value;
}
if (dateTo.value) {
params.date_to = dateTo.value;
}
router.get(route("callLaters.index"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}
function clearFilters() {
search.value = "";
dateFrom.value = "";
dateTo.value = "";
applyFilters();
}
function markDone(item) {
router.patch(
route("callLaters.complete", item.id),
{},
{
preserveScroll: true,
}
);
}
function openAndComplete(item) {
router.patch(
route("callLaters.complete", item.id),
{},
{
preserveScroll: false,
onSuccess: () => {
if (item.client_case?.uuid) {
router.visit(route("clientCase.show", { client_case: item.client_case.uuid }));
}
},
}
);
}
function isOverdue(item) {
if (!item.call_back_at) return false;
// Strip Z so the value is parsed as local time (datetimes are stored as local time with a wrong Z suffix)
const localDateStr = item.call_back_at.replace("Z", "").replace("T", " ");
return new Date(localDateStr) < new Date();
}
const columns = [
{ key: "person", label: "Stranka / Primer", sortable: false },
{ key: "contract", label: "Pogodba", sortable: false },
{ key: "call_back_at", label: "Datum klica", sortable: false },
{ key: "user", label: "Agent", sortable: false },
{ key: "note", label: "Opomba", sortable: false },
{ key: "actions", label: "", sortable: false, class: "w-12" },
];
</script>
<template>
<AppLayout title="Pokliči kasneje">
<template #header></template>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<PhoneCallIcon :size="18" />
<CardTitle class="uppercase">Pokliči kasneje</CardTitle>
</div>
</template>
<DataTable
:columns="columns"
:data="callLaters.data || []"
:meta="callLaters"
:search="search"
route-name="callLaters.index"
:show-toolbar="true"
:show-pagination="false"
:hoverable="true"
row-key="id"
empty-text="Ni zakazanih klicev."
:row-class="(row) => (isOverdue(row) ? 'bg-red-50 dark:bg-red-950/20' : '')"
>
<template #toolbar-filters>
<AppPopover
v-model:open="filterPopoverOpen"
align="start"
content-class="w-[420px]"
>
<template #trigger>
<Button variant="outline" size="sm" class="gap-2">
<Filter class="h-4 w-4" />
Filtri
<span
v-if="appliedFilterCount > 0"
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
>
{{ appliedFilterCount }}
</span>
</Button>
</template>
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Filtri klicev</h4>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<InputLabel>Iskanje (stranka)</InputLabel>
<Input
v-model="search"
type="text"
placeholder="Ime stranke..."
@keydown.enter="applyFilters"
/>
</div>
<div class="space-y-1.5">
<InputLabel>Datum od</InputLabel>
<Input v-model="dateFrom" type="date" />
</div>
<div class="space-y-1.5">
<InputLabel>Datum do</InputLabel>
<Input v-model="dateTo" type="date" />
</div>
<div class="flex justify-end gap-2 pt-2 border-t">
<Button
type="button"
variant="outline"
size="sm"
:disabled="appliedFilterCount === 0"
@click="clearFilters"
>
Počisti
</Button>
<Button type="button" size="sm" @click="applyFilters">
Uporabi
</Button>
</div>
</div>
</div>
</AppPopover>
</template>
<template #cell-person="{ row }">
<div>
<Link
v-if="row.client_case"
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
class="font-medium text-indigo-600 hover:underline"
>
{{ row.client_case.person?.full_name || "-" }}
</Link>
<span v-else class="text-muted-foreground">-</span>
</div>
</template>
<template #cell-contract="{ row }">
<span v-if="row.contract">{{ row.contract.reference }}</span>
<span v-else class="text-muted-foreground">-</span>
</template>
<template #cell-call_back_at="{ row }">
<span
:class="[
'font-medium',
isOverdue(row) ? 'text-red-600 dark:text-red-400' : '',
]"
>
{{ fmtDateTime(row.call_back_at) }}
</span>
<span v-if="isOverdue(row)" class="ml-2 text-xs text-red-500 font-semibold">
Zamuda
</span>
</template>
<template #cell-user="{ row }">
<span v-if="row.user">{{ row.user.name }}</span>
<span v-else class="text-muted-foreground">-</span>
</template>
<template #cell-note="{ row }">
<span class="line-clamp-2 text-sm text-muted-foreground">
{{ row.activity?.note || "-" }}
</span>
</template>
<template #cell-actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="icon" variant="ghost" class="h-8 w-8">
<MoreHorizontalIcon class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="markDone(row)">
<CheckIcon class="mr-2 h-4 w-4" />
Opravljeno
</DropdownMenuItem>
<DropdownMenuItem
v-if="row.client_case?.uuid"
@click="openAndComplete(row)"
>
<ExternalLinkIcon class="mr-2 h-4 w-4" />
Odpri in opravi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTable>
<div class="border-t border-gray-200 p-4">
<Pagination
:links="callLaters.links"
:from="callLaters.from"
:to="callLaters.to"
:total="callLaters.total"
:per-page="callLaters.per_page || 50"
:last-page="callLaters.last_page"
:current-page="callLaters.current_page"
/>
</div>
</AppCard>
</div>
</div>
</AppLayout>
</template>
@@ -54,10 +54,14 @@ const form = useInertiaForm({
props.actions[0].decisions.length > 0 props.actions[0].decisions.length > 0
? props.actions[0].decisions[0].id ? props.actions[0].decisions[0].id
: null, : null,
contract_uuids: props.contractUuid ? [props.contractUuid] : [], contract_uuids: props.contractUuid
? [props.contractUuid]
: (props.contracts || []).filter((item) => item.active == 1).map((c) => c.uuid),
send_auto_mail: true, send_auto_mail: true,
attach_documents: false, attach_documents: false,
attachment_document_ids: [], attachment_document_ids: [],
call_back_at_date: null,
call_back_at_time: null,
}); });
watch( watch(
@@ -96,7 +100,9 @@ watch(
watch( watch(
() => props.contractUuid, () => props.contractUuid,
(cu) => { (cu) => {
form.contract_uuids = cu ? [cu] : []; form.contract_uuids = cu
? [cu]
: (props.contracts || []).filter((item) => item.active == 1).map((c) => c.uuid);
} }
); );
@@ -104,7 +110,9 @@ watch(
() => props.show, () => props.show,
(visible) => { (visible) => {
if (visible) { if (visible) {
form.contract_uuids = props.contractUuid ? [props.contractUuid] : []; form.contract_uuids = props.contractUuid
? [props.contractUuid]
: (props.contracts || []).filter((item) => item.active == 1).map((c) => c.uuid);
} }
} }
); );
@@ -127,6 +135,20 @@ const store = async () => {
const isMultipleContracts = contractUuids && contractUuids.length > 1; const isMultipleContracts = contractUuids && contractUuids.length > 1;
const buildCallBackAt = (date, time) => {
if (!date) return null;
const t = time || "00:00";
const [h, m] = t.split(":");
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return null;
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const dy = String(d.getDate()).padStart(2, "0");
const hh = String(Number(h || 0)).padStart(2, "0");
const mm = String(Number(m || 0)).padStart(2, "0");
return `${y}-${mo}-${dy} ${hh}:${mm}:00`;
};
form form
.transform((data) => ({ .transform((data) => ({
...data, ...data,
@@ -138,11 +160,23 @@ const store = async () => {
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
? data.attachment_document_ids ? data.attachment_document_ids
: [], : [],
call_back_at: hasCallLaterEvent.value
? buildCallBackAt(data.call_back_at_date, data.call_back_at_time)
: null,
call_back_at_date: undefined,
call_back_at_time: undefined,
})) }))
.post(route("clientCase.activity.store", props.client_case), { .post(route("clientCase.activity.store", props.client_case), {
onSuccess: () => { onSuccess: () => {
close(); close();
form.reset("due_date", "amount", "note", "contract_uuids"); form.reset(
"due_date",
"amount",
"note",
"contract_uuids",
"call_back_at_date",
"call_back_at_time"
);
emit("saved"); emit("saved");
}, },
}); });
@@ -156,6 +190,22 @@ const currentDecision = () => {
decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0] || null decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0] || null
); );
}; };
const hasCallLaterEvent = computed(() => {
const d = currentDecision();
if (!d) return false;
return Array.isArray(d.events) && d.events.some((e) => e.key === "add_call_later");
});
watch(
() => hasCallLaterEvent.value,
(has) => {
if (!has) {
form.call_back_at_date = null;
form.call_back_at_time = null;
}
}
);
const showSendAutoMail = () => { const showSendAutoMail = () => {
const d = currentDecision(); const d = currentDecision();
return !!(d && d.auto_mail && d.email_template_id); return !!(d && d.auto_mail && d.email_template_id);
@@ -178,7 +228,7 @@ const autoMailRequiresContract = computed(() => {
const contractItems = computed(() => { const contractItems = computed(() => {
return pageContracts.value.map((c) => ({ return pageContracts.value.map((c) => ({
value: c.uuid, value: c.uuid,
label: `${c.reference}${c.name ? ` - ${c.name}` : ""}`, label: c.active == 1 ? `${c.reference}` : `${c.reference} (Arhivirano)`,
})); }));
}); });
@@ -409,6 +459,26 @@ watch(
/> />
</div> </div>
<div v-if="hasCallLaterEvent" class="space-y-2">
<Label>Datum in ura povratnega klica</Label>
<div class="flex gap-2">
<DatePicker
v-model="form.call_back_at_date"
format="dd.MM.yyyy"
:error="form.errors.call_back_at"
class="flex-1"
/>
<input
v-model="form.call_back_at_time"
type="time"
class="flex-1 border rounded-md px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<p v-if="form.errors.call_back_at" class="text-xs text-destructive">
{{ form.errors.call_back_at }}
</p>
</div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="activityAmount">Znesek</Label> <Label for="activityAmount">Znesek</Label>
<CurrencyInput <CurrencyInput
@@ -480,7 +550,7 @@ watch(
/> />
<div class="wrap-anywhere"> <div class="wrap-anywhere">
<p> <p>
{{ doc.original_name || doc.name }} <span>{{ doc.name }}.{{ doc.extension }}</span>
</p> </p>
<span class="text-xs text-gray-400" <span class="text-xs text-gray-400"
>({{ doc.extension?.toUpperCase() || "" }}, >({{ doc.extension?.toUpperCase() || "" }},
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref, computed, useSlots, watch, onMounted } from "vue"; import { ref, computed, useSlots, watch, onMounted } from "vue";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import axios from "axios";
import DataTable from "@/Components/DataTable/DataTableNew2.vue"; import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue"; import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@@ -20,7 +21,13 @@ import {
} from "@/Components/ui/command"; } from "@/Components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import { RangeCalendar } from "@/Components/ui/range-calendar"; import { RangeCalendar } from "@/Components/ui/range-calendar";
import { CalendarIcon, X, Filter, Check, ChevronsUpDown } from "lucide-vue-next"; import { CalendarIcon, X, Filter, Check, ChevronsUpDown, MailIcon } from "lucide-vue-next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DateFormatter, getLocalTimeZone, parseDate } from "@internationalized/date"; import { DateFormatter, getLocalTimeZone, parseDate } from "@internationalized/date";
@@ -242,6 +249,7 @@ const columns = [
{ key: "note", label: "Opomba", sortable: false }, { key: "note", label: "Opomba", sortable: false },
{ key: "promise", label: "Obljuba", sortable: false }, { key: "promise", label: "Obljuba", sortable: false },
{ key: "user", label: "Dodal", sortable: false }, { key: "user", label: "Dodal", sortable: false },
{ key: "email_action", label: "Akcija", sortable: false, align: "center" },
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" }, { key: "actions", label: "", sortable: false, hideable: false, align: "center" },
]; ];
@@ -301,6 +309,27 @@ const deleteActivity = (row) => {
const confirmDelete = ref(false); const confirmDelete = ref(false);
const toDeleteRow = ref(null); const toDeleteRow = ref(null);
// Email body dialog
const emailBodyDialogOpen = ref(false);
const emailBodyHtml = ref("");
const emailBodyLoading = ref(false);
const emailBodyError = ref(null);
const openEmailBody = async (emailLogId) => {
emailBodyHtml.value = "";
emailBodyError.value = null;
emailBodyLoading.value = true;
emailBodyDialogOpen.value = true;
try {
const res = await axios.get(route("admin.email-logs.body", emailLogId));
emailBodyHtml.value = res.data.html ?? "";
} catch (err) {
emailBodyError.value = "Napaka pri nalaganju vsebine e-pošte.";
} finally {
emailBodyLoading.value = false;
}
};
const openDelete = (row) => { const openDelete = (row) => {
toDeleteRow.value = row; toDeleteRow.value = row;
confirmDelete.value = true; confirmDelete.value = true;
@@ -741,8 +770,16 @@ const copyToClipboard = async (text) => {
<span class="text-gray-500">D:</span> <span class="text-gray-500">D:</span>
<span class="ml-1">{{ fmtDate(row.due_date) }}</span> <span class="ml-1">{{ fmtDate(row.due_date) }}</span>
</div> </div>
<div v-if="row.call_back_at" class="leading-tight">
<span class="text-gray-500">K:</span>
<span class="ml-1">{{ fmtDateTime(row.call_back_at) }}</span>
</div>
<div <div
v-if="!row.due_date && (!row.amount || Number(row.amount) === 0)" v-if="
!row.due_date &&
(!row.amount || Number(row.amount) === 0) &&
!row.call_back_at
"
class="text-gray-400" class="text-gray-400"
> >
@@ -763,6 +800,21 @@ const copyToClipboard = async (text) => {
</div> </div>
</template> </template>
<template #cell-email_action="{ row }">
<div class="flex justify-center">
<Button
v-if="row.email_logs?.length"
variant="ghost"
size="icon"
class="h-7 w-7 text-blue-600 hover:text-blue-800"
:title="'Prikaži poslano e-pošto'"
@click="openEmailBody(row.email_logs[0].id)"
>
<MailIcon class="h-4 w-4" />
</Button>
</div>
</template>
<template #cell-actions="{ row }" v-if="edit"> <template #cell-actions="{ row }" v-if="edit">
<TableActions align="right"> <TableActions align="right">
<template #default> <template #default>
@@ -786,4 +838,27 @@ const copyToClipboard = async (text) => {
@close="cancelDelete" @close="cancelDelete"
@confirm="confirmDeleteAction" @confirm="confirmDeleteAction"
/> />
<Dialog v-model:open="emailBodyDialogOpen">
<DialogContent class="max-w-4xl w-full p-0 overflow-hidden">
<DialogHeader class="px-6 pt-6 pb-0">
<DialogTitle>Vsebina poslane e-pošte</DialogTitle>
</DialogHeader>
<div class="px-6 pb-6 pt-4">
<div v-if="emailBodyLoading" class="flex items-center justify-center h-64 text-muted-foreground">
Nalaganje…
</div>
<div v-else-if="emailBodyError" class="text-destructive py-8 text-center">
{{ emailBodyError }}
</div>
<iframe
v-else
:srcdoc="emailBodyHtml"
sandbox="allow-same-origin"
class="w-full border rounded"
style="height: 600px;"
/>
</div>
</DialogContent>
</Dialog>
</template> </template>
@@ -15,6 +15,8 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
import CaseObjectsDialog from "./CaseObjectsDialog.vue"; import CaseObjectsDialog from "./CaseObjectsDialog.vue";
import PaymentDialog from "./PaymentDialog.vue"; import PaymentDialog from "./PaymentDialog.vue";
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue"; import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
import InstallmentDialog from "./InstallmentDialog.vue";
import ViewInstallmentsDialog from "./ViewInstallmentsDialog.vue";
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue"; import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue"; import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue"; import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
@@ -31,6 +33,7 @@ import {
faSpinner, faSpinner,
faTags, faTags,
faFolderOpen, faFolderOpen,
faArrowUp,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import EmptyState from "@/Components/EmptyState.vue"; import EmptyState from "@/Components/EmptyState.vue";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
@@ -444,6 +447,52 @@ const closePaymentsDialog = () => {
selectedContract.value = null; selectedContract.value = null;
}; };
// Installments
const showInstallmentDialog = ref(false);
const installmentContract = ref(null);
const installmentForm = useForm({
amount: null,
currency: "EUR",
installment_at: null,
reference: "",
});
const openInstallmentDialog = (c) => {
installmentContract.value = c;
installmentForm.reset();
installmentForm.installment_at = todayStr.value;
showInstallmentDialog.value = true;
};
const closeInstallmentDialog = () => {
showInstallmentDialog.value = false;
installmentContract.value = null;
};
const submitInstallment = () => {
if (!installmentContract.value?.account?.id) return;
const accountId = installmentContract.value.account.id;
installmentForm.post(route("accounts.installments.store", { account: accountId }), {
preserveScroll: true,
onSuccess: () => {
closeInstallmentDialog();
router.reload({ only: ["contracts", "activities"] });
},
});
};
const showInstallmentsDialog = ref(false);
const openInstallmentsDialog = (c) => {
selectedContract.value = c;
showInstallmentsDialog.value = true;
};
const closeInstallmentsDialog = () => {
showInstallmentsDialog.value = false;
selectedContract.value = null;
};
// Meta edit dialog // Meta edit dialog
const showMetaEditDialog = ref(false); const showMetaEditDialog = ref(false);
@@ -489,7 +538,7 @@ const availableSegmentsCount = computed(() => {
:empty-icon="faFolderOpen" :empty-icon="faFolderOpen"
empty-text="Ni pogodb" empty-text="Ni pogodb"
empty-description="Za ta primer še ni ustvarjenih pogodb. Ustvarite novo pogodbo za začetek." empty-description="Za ta primer še ni ustvarjenih pogodb. Ustvarite novo pogodbo za začetek."
:show-pagination="false" :show-pagination="true"
:show-toolbar="true" :show-toolbar="true"
:hoverable="true" :hoverable="true"
> >
@@ -750,7 +799,6 @@ const availableSegmentsCount = computed(() => {
<!-- Add Activity --> <!-- Add Activity -->
<ActionMenuItem <ActionMenuItem
v-if="row.active"
:icon="faListCheck" :icon="faListCheck"
label="Dodaj aktivnost" label="Dodaj aktivnost"
@click="onAddActivity(row)" @click="onAddActivity(row)"
@@ -833,6 +881,26 @@ const availableSegmentsCount = computed(() => {
@click="openPaymentDialog(row)" @click="openPaymentDialog(row)"
/> />
<div class="my-1 border-t border-gray-100" />
<!-- Installments -->
<div
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
>
Obroki
</div>
<ActionMenuItem
:icon="faCircleInfo"
label="Pokaži obroke"
@click="openInstallmentsDialog(row)"
/>
<ActionMenuItem
v-if="row.active && row?.account"
:icon="faArrowUp"
label="Dodaj obrok"
@click="openInstallmentDialog(row)"
/>
<!-- Archive --> <!-- Archive -->
<template v-if="edit"> <template v-if="edit">
<div class="my-1 border-t border-gray-100" /> <div class="my-1 border-t border-gray-100" />
@@ -938,6 +1006,20 @@ const availableSegmentsCount = computed(() => {
:edit="edit" :edit="edit"
/> />
<InstallmentDialog
:show="showInstallmentDialog"
:form="installmentForm"
@close="closeInstallmentDialog"
@submit="submitInstallment"
/>
<ViewInstallmentsDialog
:show="showInstallmentsDialog"
:contract="selectedContract"
@close="closeInstallmentsDialog"
:edit="edit"
/>
<ContractMetaEditDialog <ContractMetaEditDialog
:show="showMetaEditDialog" :show="showMetaEditDialog"
:client_case="client_case" :client_case="client_case"
@@ -0,0 +1,82 @@
<script setup>
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import CurrencyInput from "@/Components/CurrencyInput.vue";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import DatePicker from "@/Components/DatePicker.vue";
const props = defineProps({
show: { type: Boolean, default: false },
form: { type: Object, required: true },
});
const emit = defineEmits(["close", "submit"]);
const onClose = () => emit("close");
const onSubmit = () => emit("submit");
</script>
<template>
<CreateDialog
:show="show"
title="Dodaj obrok"
confirm-text="Shrani"
:processing="form.processing"
@close="onClose"
@confirm="onSubmit"
>
<div class="space-y-4">
<div class="space-y-2">
<Label for="installmentAmount">Znesek</Label>
<CurrencyInput
id="installmentAmount"
v-model="form.amount"
:precision="{ min: 0, max: 2 }"
placeholder="0,00"
class="w-full"
/>
<p v-if="form.errors?.amount" class="text-sm text-red-600">
{{ form.errors.amount }}
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="installmentCurrency">Valuta</Label>
<Input
id="installmentCurrency"
type="text"
v-model="form.currency"
maxlength="3"
placeholder="EUR"
/>
<p v-if="form.errors?.currency" class="text-sm text-red-600">
{{ form.errors.currency }}
</p>
</div>
<div class="space-y-2">
<Label for="installmentDate">Datum</Label>
<DatePicker
id="installmentDate"
v-model="form.installment_at"
format="dd.MM.yyyy"
:error="form.errors?.installment_at"
/>
</div>
</div>
<div class="space-y-2">
<Label for="installmentReference">Sklic</Label>
<Input
id="installmentReference"
type="text"
v-model="form.reference"
placeholder="Sklic"
/>
<p v-if="form.errors?.reference" class="text-sm text-red-600">
{{ form.errors.reference }}
</p>
</div>
</div>
</CreateDialog>
</template>
@@ -0,0 +1,160 @@
<script setup>
import DialogModal from "@/Components/DialogModal.vue";
import { ref, watch, computed } from "vue";
import { router } from "@inertiajs/vue3";
import axios from "axios";
const props = defineProps({
show: { type: Boolean, default: false },
contract: { type: Object, default: null },
edit: { type: Boolean, default: true },
});
const emit = defineEmits(["close"]);
const installments = ref([]);
const loading = ref(false);
const contractRef = computed(() => props.contract?.reference || "—");
const accountId = computed(() => props.contract?.account?.id || null);
function formatDate(d) {
if (!d) return "-";
const dt = new Date(d);
return isNaN(dt.getTime()) ? "-" : dt.toLocaleDateString("de");
}
async function loadInstallments() {
if (!accountId.value) {
installments.value = [];
return;
}
loading.value = true;
try {
const { data } = await axios.get(
route("accounts.installments.list", { account: accountId.value })
);
installments.value = data.installments || [];
} finally {
loading.value = false;
}
}
function close() {
emit("close");
installments.value = [];
}
function deleteInstallment(installmentId) {
if (!accountId.value) return;
router.delete(
route("accounts.installments.destroy", {
account: accountId.value,
installment: installmentId,
}),
{
preserveScroll: true,
preserveState: true,
only: ["contracts", "activities"],
onSuccess: async () => {
await loadInstallments();
},
onError: async () => {
await loadInstallments();
},
}
);
}
watch(
() => props.show,
async (visible) => {
if (visible) {
await loadInstallments();
}
}
);
watch(
() => props.contract?.account?.id,
async () => {
if (props.show) {
await loadInstallments();
}
}
);
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
Obroki za pogodbo <span class="text-gray-600">{{ contractRef }}</span>
</template>
<template #content>
<div>
<div v-if="loading" class="text-sm text-gray-500">Nalaganje</div>
<template v-else>
<div v-if="installments.length === 0" class="text-sm text-gray-500">Ni obrokov.</div>
<div v-else class="divide-y divide-gray-100 border rounded">
<div
v-for="i in installments"
:key="i.id"
class="px-3 py-2 flex items-center justify-between"
>
<div>
<div class="text-sm text-gray-800">
{{
Intl.NumberFormat("de-DE", {
style: "currency",
currency: i.currency || "EUR",
}).format(i.amount ?? 0)
}}
</div>
<div class="text-xs text-gray-500">
<span>{{ formatDate(i.installment_at) }}</span>
<span v-if="i.reference" class="ml-2">Sklic: {{ i.reference }}</span>
<span v-if="i.balance_before !== undefined" class="ml-2">
Stanje pred:
{{
Intl.NumberFormat("de-DE", {
style: "currency",
currency: i.currency || "EUR",
}).format(i.balance_before ?? 0)
}}
</span>
</div>
</div>
<div class="flex items-center gap-2" v-if="edit">
<button
type="button"
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
@click="deleteInstallment(i.id)"
title="Izbriši obrok"
>
<span class="text-sm">Briši</span>
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<button
type="button"
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
@click="loadInstallments"
>
Osveži
</button>
<button
type="button"
class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
@click="close"
>
Zapri
</button>
</div>
</template>
</DialogModal>
</template>
+3 -15
View File
@@ -31,7 +31,7 @@ import {
const props = defineProps({ const props = defineProps({
client: Object, client: Object,
client_case: Object, client_case: Object,
contracts: Object, // Resource Collection with data/links/meta contracts: { type: Array, default: () => [] }, // Resource Collection with data/links/meta
activities: Object, // Resource Collection with data/links/meta activities: Object, // Resource Collection with data/links/meta
contract_types: Array, contract_types: Array,
account_types: { type: Array, default: () => [] }, account_types: { type: Array, default: () => [] },
@@ -46,7 +46,7 @@ const props = defineProps({
// Extract contracts array from Resource Collection // Extract contracts array from Resource Collection
const contractsArray = computed(() => { const contractsArray = computed(() => {
return props.contracts?.data || []; return props.contracts || [];
}); });
// Contracts are always paginated now (Resource Collection) // Contracts are always paginated now (Resource Collection)
@@ -324,6 +324,7 @@ const submitAttachSegment = () => {
:person="client_case.person" :person="client_case.person"
:person-edit="hasPerm('person-edit')" :person-edit="hasPerm('person-edit')"
:enable-sms="true" :enable-sms="true"
:enable-email="true"
:client-case-uuid="client_case.uuid" :client-case-uuid="client_case.uuid"
/> />
</div> </div>
@@ -356,19 +357,6 @@ const submitAttachSegment = () => {
@create="openDrawerCreateContract" @create="openDrawerCreateContract"
@attach-segment="openAttachSegment" @attach-segment="openAttachSegment"
/> />
<div v-if="contractsPaginated" class="border-t border-gray-200 p-4">
<Pagination
:links="contracts.links"
:from="contracts.from"
:to="contracts.to"
:total="contracts.total"
:per-page="contracts.per_page || 50"
:last-page="contracts.last_page"
:current-page="contracts.current_page"
per-page-param="contracts_per_page"
page-param="contracts_page"
/>
</div>
</div> </div>
</Card> </Card>
</div> </div>

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