Compare commits
45 Commits
d64a67cf76
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ffc60aba5 | |||
| 7ab890005b | |||
| b6405764a9 | |||
| 256b311c43 | |||
| 32fe2fbc9b | |||
| e3bc5da7e3 | |||
| b6bfa17980 | |||
| fd81e8ce2d | |||
| 054202dc32 | |||
| 92f54f7103 | |||
| 8f8c5c5a12 | |||
| 187cb4f127 | |||
| 7881508a7b | |||
| 821985469e | |||
| a5257df2b7 | |||
| 342d9d0700 | |||
| d54fc9914d | |||
| f8d1579cb2 | |||
| d80c99c6c0 | |||
| 9c773be3ec | |||
| 9c6878d1bd | |||
| 5f9d00b575 | |||
| 2cc765912e | |||
| b6e66f0e64 | |||
| 0aa95fba47 | |||
| 0b082549b9 | |||
| b0d2aa93ab | |||
| c16dd51199 | |||
| 245caea4dc | |||
| dda118a005 | |||
| 8147fedd04 | |||
| b1c531bb70 | |||
| 9cc1b7072c | |||
| 2968bcf3f8 | |||
| ad0f7a7a01 | |||
| 368b0a7cf7 | |||
| aa375ce0da | |||
| 340e16c610 | |||
| 33b236d881 | |||
| fb7704027b | |||
| e5902706f1 | |||
| 229c100cc4 | |||
| 9a4897bf0c | |||
| d779e4d7a1 | |||
| b2a9350d0f |
@@ -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\EmailTemplate;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -69,4 +70,15 @@ public function show(EmailLog $emailLog): Response
|
||||
'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\EmailLogStatus;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\MailProfile;
|
||||
use App\Services\EmailTemplateRenderer;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -55,8 +56,14 @@ public function create(): Response
|
||||
{
|
||||
$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', [
|
||||
'template' => null,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -93,7 +100,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
||||
// Context resolution (shared logic with renderFinalHtml)
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
// 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')) {
|
||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
|
||||
if ($contract) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
@@ -140,6 +147,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
||||
}
|
||||
}
|
||||
$ctx['extra'] = (array) $request->input('extra', []);
|
||||
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
$rendered = $renderer->render([
|
||||
'subject' => $subject,
|
||||
@@ -161,8 +169,14 @@ public function edit(EmailTemplate $emailTemplate): Response
|
||||
$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', [
|
||||
'template' => $emailTemplate,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -181,7 +195,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
// Context resolution
|
||||
$ctx = [];
|
||||
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) {
|
||||
$ctx['activity'] = $activity;
|
||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||
@@ -197,7 +211,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
@@ -227,6 +241,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
$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
|
||||
$rendered = $renderer->render([
|
||||
@@ -293,7 +308,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
||||
// Context resolution (same as sendTest)
|
||||
$ctx = [];
|
||||
if ($id = $request->integer('activity_id')) {
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||
if ($activity) {
|
||||
$ctx['activity'] = $activity;
|
||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||
@@ -309,7 +324,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
$ctx['contract'] = $contract;
|
||||
if ($contract->clientCase) {
|
||||
@@ -339,6 +354,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
||||
}
|
||||
}
|
||||
$ctx['extra'] = (array) $request->input('extra', []);
|
||||
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||
|
||||
$rendered = $renderer->render([
|
||||
'subject' => $subject,
|
||||
|
||||
@@ -26,7 +26,7 @@ public function index(): Response
|
||||
->orderBy('priority')
|
||||
->orderBy('id')
|
||||
->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', [
|
||||
@@ -76,6 +76,15 @@ public function toggle(Request $request, MailProfile $mailProfile)
|
||||
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)
|
||||
{
|
||||
$this->authorize('test', $mailProfile);
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEmailPackageFromContractsRequest;
|
||||
use App\Http\Requests\StorePackageFromContractsRequest;
|
||||
use App\Http\Requests\StorePackageRequest;
|
||||
use App\Jobs\PackageItemEmailJob;
|
||||
use App\Jobs\PackageItemSmsJob;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackageItem;
|
||||
use App\Models\SmsTemplate;
|
||||
use App\Services\Contact\EmailSelector;
|
||||
use App\Services\Contact\PhoneSelector;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -21,20 +24,40 @@
|
||||
|
||||
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;
|
||||
|
||||
$packages = Package::query()
|
||||
->where('type', Package::TYPE_SMS)
|
||||
->latest('id')
|
||||
->paginate($perPage);
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
return Inertia::render('Packages/Sms/Index', [
|
||||
'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)
|
||||
$profiles = \App\Models\SmsProfile::query()
|
||||
@@ -50,6 +73,7 @@ public function create(Request $request): Response
|
||||
->get(['id', 'name', 'content']);
|
||||
$segments = \App\Models\Segment::query()
|
||||
->where('active', true)
|
||||
->where('exclude', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
// Provide a lightweight list of recent clients with person names for filtering
|
||||
@@ -68,7 +92,7 @@ public function create(Request $request): Response
|
||||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Admin/Packages/Create', [
|
||||
return Inertia::render('Packages/Sms/Create', [
|
||||
'profiles' => $profiles,
|
||||
'senders' => $senders,
|
||||
'templates' => $templates,
|
||||
@@ -77,7 +101,53 @@ public function create(Request $request): Response
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Package $package, SmsService $sms): Response
|
||||
public function emailCreate(): Response
|
||||
{
|
||||
$emailTemplates = \App\Models\EmailTemplate::query()
|
||||
->where('active', true)
|
||||
->where('client', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'subject_template', 'text_template', 'html_template'])
|
||||
->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'name' => $t->name,
|
||||
'subject_template' => $t->subject_template,
|
||||
'text_template' => $t->text_template,
|
||||
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $t->html_template),
|
||||
])->values();
|
||||
$mailProfiles = \App\Models\MailProfile::query()
|
||||
->where('active', true)
|
||||
->orderBy('priority')
|
||||
->get(['id', 'name']);
|
||||
$segments = \App\Models\Segment::query()
|
||||
->where('active', true)
|
||||
->where('exclude', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
$clients = \App\Models\Client::query()
|
||||
->with(['person' => function ($q) {
|
||||
$q->select('id', 'uuid', 'full_name');
|
||||
}])
|
||||
->latest('id')
|
||||
->get(['id', 'uuid', 'person_id'])
|
||||
->map(function ($c) {
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'uuid' => $c->uuid,
|
||||
'name' => $c->person?->full_name ?? ('Client #'.$c->id),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Packages/Mail/Create', [
|
||||
'emailTemplates' => $emailTemplates,
|
||||
'mailProfiles' => $mailProfiles,
|
||||
'segments' => $segments,
|
||||
'clients' => $clients,
|
||||
]);
|
||||
}
|
||||
|
||||
public function smsShow(Package $package, SmsService $sms): Response
|
||||
{
|
||||
$items = $package->items()->latest('id')->paginate(25);
|
||||
|
||||
@@ -211,13 +281,23 @@ public function show(Package $package, SmsService $sms): Response
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Admin/Packages/Show', [
|
||||
return Inertia::render('Packages/Sms/Show', [
|
||||
'package' => $package,
|
||||
'items' => $items,
|
||||
'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
|
||||
{
|
||||
$data = $request->validated();
|
||||
@@ -259,7 +339,11 @@ public function dispatch(Package $package): RedirectResponse
|
||||
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);
|
||||
})->all();
|
||||
|
||||
@@ -285,7 +369,7 @@ public function dispatch(Package $package): RedirectResponse
|
||||
$package->save();
|
||||
}
|
||||
})
|
||||
->onQueue('sms')
|
||||
->onQueue($package->type === Package::TYPE_EMAIL ? 'email' : 'sms')
|
||||
->dispatch();
|
||||
|
||||
return back()->with('success', 'Package dispatched');
|
||||
@@ -321,7 +405,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
$request->validate([
|
||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'q' => ['nullable', 'string'],
|
||||
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'only_mobile' => ['nullable', 'boolean'],
|
||||
'only_validated' => ['nullable', 'boolean'],
|
||||
@@ -332,13 +415,13 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
]);
|
||||
|
||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||
|
||||
|
||||
$query = Contract::query()
|
||||
->with([
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client.person',
|
||||
'account',
|
||||
'segments:id,name',
|
||||
])
|
||||
->select('contracts.*')
|
||||
->latest('contracts.id');
|
||||
@@ -350,6 +433,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
->where('contract_segment.segment_id', '=', $segmentId)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
} else {
|
||||
// Only include contracts that have at least one active, non-excluded segment
|
||||
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
|
||||
->from('contract_segment')
|
||||
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
|
||||
->where('contract_segment.active', true)
|
||||
->where('segments.exclude', false)
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
);
|
||||
}
|
||||
|
||||
if ($q = trim((string) $request->input('q'))) {
|
||||
@@ -399,13 +491,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
});
|
||||
}
|
||||
|
||||
$contracts = $query->get();
|
||||
$contracts = $query->limit(500)->get();
|
||||
|
||||
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||
$person = $contract->clientCase?->person;
|
||||
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||
$phone = $selected['phone'];
|
||||
$clientPerson = $contract->clientCase?->client?->person;
|
||||
$segment = collect($contract->segments)->last();
|
||||
|
||||
return [
|
||||
'id' => $contract->id,
|
||||
@@ -423,6 +516,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
'uuid' => $person?->uuid,
|
||||
'full_name' => $person?->full_name,
|
||||
],
|
||||
'segment' => $segment,
|
||||
// Stranka: the client person
|
||||
'client' => $clientPerson ? [
|
||||
'id' => $contract->clientCase?->client?->id,
|
||||
@@ -434,13 +528,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||
'number' => $phone->nu,
|
||||
'validated' => $phone->validated,
|
||||
'type' => $phone->phone_type?->value,
|
||||
'description' => $phone->description,
|
||||
] : null,
|
||||
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $data
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -530,6 +625,213 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
|
||||
return back()->with('success', 'Package created from contracts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Extracts 'value' from objects with {title, value, type} structure.
|
||||
|
||||
@@ -20,7 +20,7 @@ public function index(Request $request): Response
|
||||
{
|
||||
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']);
|
||||
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
|
||||
|
||||
@@ -73,4 +73,17 @@ public function toggleActive(User $user): RedirectResponse
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -71,10 +71,8 @@ public function index(ClientCase $clientCase, Request $request)
|
||||
$que->whereDate('client_cases.created_at', '<=', $to);
|
||||
})
|
||||
->groupBy('client_cases.id')
|
||||
->addSelect([
|
||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->selectRaw('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')
|
||||
->with(['person.client', 'client.person'])
|
||||
->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'));
|
||||
}
|
||||
|
||||
\DB::transaction(function () use ($request, $contract) {
|
||||
$balanceChanged = false;
|
||||
$oldBalance = null;
|
||||
$newBalance = null;
|
||||
|
||||
\DB::transaction(function () use ($request, $contract, &$balanceChanged, &$oldBalance, &$newBalance) {
|
||||
$contract->update([
|
||||
'reference' => $request->input('reference'),
|
||||
'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');
|
||||
}
|
||||
if ($currentAccount) {
|
||||
$oldBalance = (float) $currentAccount->balance_amount;
|
||||
$currentAccount->update($accountData);
|
||||
if (array_key_exists('balance_amount', $accountData)) {
|
||||
$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()]);
|
||||
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
||||
}
|
||||
$newBalance = $freshBal;
|
||||
if ($oldBalance !== $freshBal) {
|
||||
$balanceChanged = true;
|
||||
}
|
||||
} else {
|
||||
$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
|
||||
$segment = request('segment');
|
||||
|
||||
@@ -306,6 +334,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
try {
|
||||
$attributes = $request->validate([
|
||||
'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',
|
||||
'note' => 'nullable|string',
|
||||
'action_id' => 'exists:\App\Models\Action,id',
|
||||
@@ -326,14 +355,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
|
||||
// Determine which contracts to process
|
||||
$contractIds = [];
|
||||
if ($createForAll && !empty($contractUuids)) {
|
||||
if ($createForAll && ! empty($contractUuids)) {
|
||||
// Get all contract IDs from the provided UUIDs
|
||||
$contracts = Contract::withTrashed()
|
||||
->whereIn('uuid', $contractUuids)
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->get();
|
||||
$contractIds = $contracts->pluck('id')->toArray();
|
||||
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
|
||||
} elseif (! empty($contractUuids) && isset($contractUuids[0])) {
|
||||
// Single contract mode
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $contractUuids[0])
|
||||
@@ -342,7 +371,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
if ($contract) {
|
||||
$contractIds = [$contract->id];
|
||||
}
|
||||
} elseif (!empty($attributes['contract_uuid'])) {
|
||||
} elseif (! empty($attributes['contract_uuid'])) {
|
||||
// Legacy single contract_uuid support
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $attributes['contract_uuid'])
|
||||
@@ -360,7 +389,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
|
||||
$createdActivities = [];
|
||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||
|
||||
|
||||
// Disable auto mail if creating activities for multiple contracts
|
||||
if ($sendFlag && count($contractIds) > 1) {
|
||||
$sendFlag = false;
|
||||
@@ -371,6 +400,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'call_back_at' => $attributes['call_back_at'] ?? null,
|
||||
'amount' => $attributes['amount'] ?? null,
|
||||
'note' => $attributes['note'] ?? null,
|
||||
'action_id' => $attributes['action_id'],
|
||||
@@ -417,29 +447,29 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
->whereIn('id', $attachmentIds)
|
||||
->pluck('id');
|
||||
$validAttachmentIds = Document::query()
|
||||
->where('documentable_type', Contract::class)
|
||||
->where('documentable_id', $contractId)
|
||||
->whereIn('id', $attachmentIds)
|
||||
->pluck('id');
|
||||
->where('documentable_type', Contract::class)
|
||||
->where('documentable_id', $contractId)
|
||||
->whereIn('id', $attachmentIds)
|
||||
->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);
|
||||
$successMessage = $activityCount > 1
|
||||
$successMessage = $activityCount > 1
|
||||
? "Successfully created {$activityCount} activities!"
|
||||
: 'Successfully created activity!';
|
||||
|
||||
@@ -602,9 +632,9 @@ public function storeDocument(ClientCase $clientCase, Request $request)
|
||||
$contract = null;
|
||||
if (! empty($validated['contract_uuid'])) {
|
||||
$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'));
|
||||
}
|
||||
}*/
|
||||
}
|
||||
$directory = $contract
|
||||
? ('contracts/'.$contract->uuid.'/documents')
|
||||
@@ -825,9 +855,8 @@ public function show(ClientCase $clientCase)
|
||||
}
|
||||
|
||||
// Get contracts using service
|
||||
$contractsPerPage = request()->integer('contracts_per_page', 10);
|
||||
$contracts = $this->caseDataService->getContracts($case, $segmentId, $contractsPerPage);
|
||||
$contractIds = collect($contracts->items())->pluck('id')->all();
|
||||
$contracts = $this->caseDataService->getContracts($case, $segmentId);
|
||||
$contractIds = collect($contracts)->pluck('id')->all();
|
||||
|
||||
// Get activities using service
|
||||
$activitiesPerPage = request()->integer('activities_per_page', 15);
|
||||
@@ -868,11 +897,14 @@ public function show(ClientCase $clientCase)
|
||||
'decisions.emailTemplate' => function ($q) {
|
||||
$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']),
|
||||
'types' => $types,
|
||||
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
|
||||
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
|
||||
'all_segments' => Segment::query()->where('active', true)->get(['id', 'name']),
|
||||
'current_segment' => $currentSegment,
|
||||
'sms_profiles' => \App\Models\SmsProfile::query()
|
||||
->select(['id', 'name', 'default_sender_id'])
|
||||
@@ -881,14 +913,27 @@ public function show(ClientCase $clientCase)
|
||||
->get(),
|
||||
'sms_senders' => \App\Models\SmsSender::query()
|
||||
->select(['id', 'profile_id'])
|
||||
->addSelect(\DB::raw('sname as name'))
|
||||
->addSelect(\DB::raw('phone_number as phone'))
|
||||
->selectRaw('sname as name')
|
||||
->selectRaw('phone_number as phone')
|
||||
->orderBy('sname')
|
||||
->get(),
|
||||
'sms_templates' => \App\Models\SmsTemplate::query()
|
||||
->select(['id', 'name', 'content', 'allow_custom_body'])
|
||||
->orderBy('name')
|
||||
->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) {
|
||||
\Log::warning('No archive settings found for batch archive');
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => 'No archive settings found',
|
||||
]);
|
||||
@@ -1115,13 +1161,14 @@ public function archiveBatch(Request $request)
|
||||
foreach ($validated['contracts'] as $contractUuid) {
|
||||
try {
|
||||
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
||||
|
||||
|
||||
// Skip if contract is already archived (active = 0)
|
||||
if (!$contract->active) {
|
||||
if (! $contract->active) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
$clientCase = $contract->clientCase;
|
||||
|
||||
$context = [
|
||||
@@ -1208,8 +1255,8 @@ public function archiveBatch(Request $request)
|
||||
if ($skippedCount > 0) {
|
||||
$message .= ", skipped $skippedCount already archived";
|
||||
}
|
||||
$message .= ", " . count($errors) . " failed";
|
||||
|
||||
$message .= ', '.count($errors).' failed';
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => $message,
|
||||
'details' => $errors,
|
||||
@@ -1219,7 +1266,7 @@ public function archiveBatch(Request $request)
|
||||
$message = $reactivate
|
||||
? "Successfully reactivated $successCount contracts"
|
||||
: "Successfully archived $successCount contracts";
|
||||
|
||||
|
||||
if ($skippedCount > 0) {
|
||||
$message .= " ($skippedCount already archived)";
|
||||
}
|
||||
@@ -1345,10 +1392,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
||||
if (! empty($validated['sender_id'])) {
|
||||
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
|
||||
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) {
|
||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||
}
|
||||
}
|
||||
if (! $profile) {
|
||||
@@ -1391,7 +1438,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
'note' => $activityNote,
|
||||
'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.
|
||||
* 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
|
||||
{
|
||||
$result = [];
|
||||
|
||||
@@ -27,7 +27,7 @@ public function index(Client $client, Request $request)
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('clients.id');
|
||||
})
|
||||
->where('clients.active', 1)
|
||||
// ->where('clients.active', 1)
|
||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||
->leftJoin('contracts', function ($join) {
|
||||
@@ -40,18 +40,14 @@ public function index(Client $client, Request $request)
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('clients.id')
|
||||
->addSelect([
|
||||
// Number of client cases for this client that have at least one active contract
|
||||
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'),
|
||||
])
|
||||
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_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')
|
||||
->with('person')
|
||||
->orderByDesc('clients.created_at');
|
||||
|
||||
return Inertia::render('Client/Index', [
|
||||
'clients' => $query
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->paginate($request->integer('per_page', default: 100))
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
@@ -71,6 +67,7 @@ public function show(Client $client, Request $request)
|
||||
|
||||
return Inertia::render('Client/Show', [
|
||||
'client' => $data,
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
'client_cases' => $data->clientCases()
|
||||
->select('client_cases.*')
|
||||
->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')
|
||||
->groupBy('client_cases.id')
|
||||
->addSelect([
|
||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->selectRaw('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')
|
||||
->with(['person', 'client.person'])
|
||||
->where('client_cases.active', 1)
|
||||
->orderByDesc('client_cases.created_at')
|
||||
@@ -162,6 +157,7 @@ public function contracts(Client $client, Request $request)
|
||||
|
||||
return Inertia::render('Client/Contracts', [
|
||||
'client' => $data,
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
'contracts' => $contractsQuery
|
||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
||||
->withQueryString(),
|
||||
@@ -175,7 +171,7 @@ public function exportContracts(ExportClientContractsRequest $request, Client $c
|
||||
{
|
||||
$data = $request->validated();
|
||||
$columns = array_values(array_unique($data['columns']));
|
||||
|
||||
|
||||
$from = $data['from'] ?? null;
|
||||
$to = $data['to'] ?? null;
|
||||
$search = $data['search'] ?? null;
|
||||
@@ -236,7 +232,7 @@ private function buildExportFilename(Client $client): string
|
||||
{
|
||||
$datePrefix = now()->format('dmy');
|
||||
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
|
||||
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -47,9 +46,9 @@ public function __invoke(SmsService $sms): Response
|
||||
return Account::whereHas('contract', function ($q) {
|
||||
$q->whereNull('deleted_at');
|
||||
})
|
||||
->whereNotNull('promise_date')
|
||||
->whereDate('promise_date', '>=', $today)
|
||||
->count();
|
||||
->whereNotNull('promise_date')
|
||||
->whereDate('promise_date', '>=', $today)
|
||||
->count();
|
||||
});
|
||||
|
||||
// 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'));
|
||||
|
||||
$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')
|
||||
->pluck('c', 'd');
|
||||
|
||||
// Completed field jobs last 7 days
|
||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||
->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')
|
||||
->pluck('c', 'd');
|
||||
|
||||
@@ -101,13 +100,13 @@ public function __invoke(SmsService $sms): Response
|
||||
// Field jobs assigned today - cached
|
||||
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||
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'])
|
||||
->with(['contract' => function ($q) {
|
||||
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||
->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)
|
||||
->get()
|
||||
->map(function ($fj) {
|
||||
@@ -120,20 +119,26 @@ public function __invoke(SmsService $sms): Response
|
||||
}
|
||||
}
|
||||
|
||||
if (! $contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $fj->id,
|
||||
'priority' => $fj->priority,
|
||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||
'created_at' => $fj->created_at?->toIso8601String(),
|
||||
'contract' => $contract ? [
|
||||
'contract' => [
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||
'segment_id' => $segmentId,
|
||||
] : null,
|
||||
],
|
||||
];
|
||||
});
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
});
|
||||
|
||||
// System health for timestamp
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
use App\Models\ImportEvent;
|
||||
use App\Models\ImportTemplate;
|
||||
use App\Services\CsvImportService;
|
||||
use App\Services\Import\ImportServiceV2;
|
||||
use App\Services\Import\ImportSimulationServiceV2;
|
||||
use App\Services\ImportProcessor;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -65,6 +64,7 @@ public function index(Request $request)
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'from' => $paginator->firstItem(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'links' => $paginator->linkCollection()->toArray(),
|
||||
'path' => $paginator->path(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'to' => $paginator->lastItem(),
|
||||
@@ -187,9 +187,10 @@ public function store(Request $request)
|
||||
public function process(Import $import, Request $request, ImportProcessor $processor)
|
||||
{
|
||||
$import->update(['status' => 'validating', 'started_at' => now()]);
|
||||
|
||||
|
||||
try {
|
||||
$result = $processor->process($import, user: $request->user());
|
||||
|
||||
return response()->json($result);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Import processing failed', [
|
||||
@@ -197,12 +198,12 @@ public function process(Import $import, Request $request, ImportProcessor $proce
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
|
||||
$import->update(['status' => 'failed']);
|
||||
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Import processing failed: ' . $e->getMessage(),
|
||||
'message' => 'Import processing failed: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -712,8 +713,6 @@ public function simulatePayments(Import $import, Request $request)
|
||||
* templates. For payments templates, payment-specific summaries/entities will be included
|
||||
* automatically by the simulation service when mappings contain the payment root.
|
||||
*
|
||||
* @param Import $import
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function simulate(Import $import, Request $request)
|
||||
@@ -829,4 +828,19 @@ public function destroy(Request $request, Import $import)
|
||||
|
||||
return back()->with('success', 'Import deleted successfully');
|
||||
}
|
||||
|
||||
// Download the original import file
|
||||
public function download(Import $import)
|
||||
{
|
||||
// Verify file exists
|
||||
if (! $import->disk || ! $import->path || ! Storage::disk($import->disk)->exists($import->path)) {
|
||||
return response()->json([
|
||||
'error' => 'File not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$fileName = $import->original_name ?? 'import_'.$import->uuid;
|
||||
|
||||
return Storage::disk($import->disk)->download($import->path, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateInstallmentSettingRequest;
|
||||
use App\Models\Action;
|
||||
use App\Models\Decision;
|
||||
use App\Models\InstallmentSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class InstallmentSettingController extends Controller
|
||||
{
|
||||
public function edit(): Response
|
||||
{
|
||||
$setting = InstallmentSetting::query()->first();
|
||||
if (! $setting) {
|
||||
$setting = InstallmentSetting::query()->create([
|
||||
'default_currency' => 'EUR',
|
||||
'create_activity_on_installment' => false,
|
||||
'default_decision_id' => null,
|
||||
'default_action_id' => null,
|
||||
'activity_note_template' => 'Dodan obrok: {amount} {currency}',
|
||||
]);
|
||||
}
|
||||
|
||||
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||
$actions = Action::query()
|
||||
->with(['decisions:id'])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (Action $a) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'decision_ids' => $a->decisions->pluck('id')->values(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Settings/Installments/Index', [
|
||||
'setting' => [
|
||||
'id' => $setting->id,
|
||||
'default_currency' => $setting->default_currency,
|
||||
'create_activity_on_installment' => (bool) $setting->create_activity_on_installment,
|
||||
'default_decision_id' => $setting->default_decision_id,
|
||||
'default_action_id' => $setting->default_action_id,
|
||||
'activity_note_template' => $setting->activity_note_template,
|
||||
],
|
||||
'decisions' => $decisions,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateInstallmentSettingRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$setting = InstallmentSetting::query()->firstOrFail();
|
||||
|
||||
$data['create_activity_on_installment'] = (bool) ($data['create_activity_on_installment'] ?? false);
|
||||
|
||||
$setting->update($data);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene.');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BankAccount;
|
||||
use App\Models\Person\Person;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -22,14 +21,14 @@ public function update(Person $person, Request $request)
|
||||
'tax_number' => 'nullable|integer',
|
||||
'social_security_number' => 'nullable|integer',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'employer' => 'nullable|string|max:255',
|
||||
'birthday' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$person->update($attributes);
|
||||
|
||||
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function createAddress(Person $person, Request $request)
|
||||
@@ -72,7 +71,7 @@ public function updateAddress(Person $person, int $address_id, Request $request)
|
||||
$address->update($attributes);
|
||||
|
||||
return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
|
||||
|
||||
|
||||
}
|
||||
|
||||
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->delete(); // soft 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_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'failed' => 'boolean',
|
||||
'receive_auto_mails' => 'sometimes|boolean',
|
||||
'verified_at' => 'nullable|date',
|
||||
'preferences' => '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
|
||||
$email = $person->emails()->firstOrCreate([
|
||||
'value' => $attributes['value'],
|
||||
@@ -160,14 +165,21 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'failed' => 'boolean',
|
||||
'receive_auto_mails' => 'sometimes|boolean',
|
||||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,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);
|
||||
|
||||
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
|
||||
$trr = $person->bankAccounts()->create($attributes);
|
||||
|
||||
|
||||
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
||||
|
||||
|
||||
}
|
||||
|
||||
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->delete();
|
||||
|
||||
|
||||
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,42 +10,40 @@
|
||||
class PhoneViewController extends Controller
|
||||
{
|
||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||
public function index(Request $request)
|
||||
|
||||
public function index(Request $request): \Inertia\Response
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$search = $request->input('search');
|
||||
$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)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->with([
|
||||
'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');
|
||||
->with($eagerLoad);
|
||||
|
||||
// Apply client filter
|
||||
if ($clientFilter) {
|
||||
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||
$baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||
$q->where('uuid', $clientFilter);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$baseQuery->where(function ($q) use ($search) {
|
||||
$q->whereHas('contract', function ($cq) use ($search) {
|
||||
$cq->where('reference', 'ilike', '%'.$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()
|
||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
|
||||
$q->where('assigned_user_id', $userId)
|
||||
@@ -77,7 +80,8 @@ public function index(Request $request)
|
||||
->values();
|
||||
|
||||
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,
|
||||
'view_mode' => 'assigned',
|
||||
'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;
|
||||
$search = $request->input('search');
|
||||
$clientFilter = $request->input('client');
|
||||
$perPage = $request->integer('per_page', 15);
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
$start = now()->startOfDay();
|
||||
$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()
|
||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
|
||||
$q->where('assigned_user_id', $userId)
|
||||
@@ -157,7 +156,7 @@ public function completedToday(Request $request)
|
||||
->values();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'completedJobs' => Inertia::scroll(fn () => $query->paginate(15, pageName: 'completed')),
|
||||
'clients' => $clients,
|
||||
'view_mode' => 'completed-today',
|
||||
'filters' => [
|
||||
|
||||
@@ -43,7 +43,7 @@ public function show(string $slug, Request $request)
|
||||
$inputs = $this->buildInputsArray($report);
|
||||
$filters = $this->validateFilters($inputs, $request);
|
||||
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
|
||||
|
||||
|
||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||
$query = $this->queryBuilder->build($report, $filters);
|
||||
$paginator = $query->paginate($perPage);
|
||||
@@ -279,16 +279,51 @@ public function clients(Request $request)
|
||||
$clients = \App\Models\Client::query()
|
||||
->with('person:id,full_name')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
->map(fn ($c) => [
|
||||
'id' => $c->uuid,
|
||||
'name' => $c->person->full_name ?? 'Unknown'
|
||||
'name' => $c->person->full_name ?? 'Unknown',
|
||||
])
|
||||
->sortBy('name')
|
||||
->values();
|
||||
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -307,6 +342,8 @@ protected function validateFilters(array $inputs, Request $request): array
|
||||
'integer' => [$nullable, 'integer'],
|
||||
'select:user' => [$nullable, 'integer', 'exists:users,id'],
|
||||
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
|
||||
'select:action' => [$nullable, 'integer', 'exists:actions,id'],
|
||||
'select:decision' => [$nullable, 'integer', 'exists:decisions,id'],
|
||||
default => [$nullable, 'string'],
|
||||
};
|
||||
}
|
||||
@@ -319,7 +356,7 @@ protected function validateFilters(array $inputs, Request $request): array
|
||||
*/
|
||||
protected function buildInputsArray(Report $report): array
|
||||
{
|
||||
return $report->filters->map(fn($filter) => [
|
||||
return $report->filters->map(fn ($filter) => [
|
||||
'key' => $filter->key,
|
||||
'type' => $filter->type,
|
||||
'label' => $filter->label,
|
||||
@@ -336,7 +373,7 @@ protected function buildColumnsArray(Report $report): array
|
||||
{
|
||||
return $report->columns
|
||||
->where('visible', true)
|
||||
->map(fn($col) => [
|
||||
->map(fn ($col) => [
|
||||
'key' => $col->key,
|
||||
'label' => $col->label,
|
||||
])
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use App\Models\Decision;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\Segment;
|
||||
use App\Services\DecisionEvents\ConditionEvaluator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
@@ -22,6 +23,8 @@ public function index(Request $request)
|
||||
'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']),
|
||||
'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)
|
||||
{
|
||||
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
||||
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
||||
|
||||
$attributes = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'color_tag' => 'nullable|string|max:25',
|
||||
@@ -96,6 +102,14 @@ public function storeDecision(Request $request)
|
||||
'events.*.active' => 'sometimes|boolean',
|
||||
'events.*.run_order' => 'nullable|integer',
|
||||
'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();
|
||||
@@ -112,12 +126,12 @@ public function storeDecision(Request $request)
|
||||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||
if ($key === 'add_segment') {
|
||||
$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.';
|
||||
}
|
||||
} elseif ($key === 'archive_contract') {
|
||||
$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.';
|
||||
}
|
||||
}
|
||||
@@ -174,6 +188,9 @@ public function updateDecision(int $id, Request $request)
|
||||
{
|
||||
$row = Decision::findOrFail($id);
|
||||
|
||||
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
||||
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
||||
|
||||
$attributes = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'color_tag' => 'nullable|string|max:25',
|
||||
@@ -187,6 +204,14 @@ public function updateDecision(int $id, Request $request)
|
||||
'events.*.active' => 'sometimes|boolean',
|
||||
'events.*.run_order' => 'nullable|integer',
|
||||
'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();
|
||||
@@ -203,12 +228,12 @@ public function updateDecision(int $id, Request $request)
|
||||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||
if ($key === 'add_segment') {
|
||||
$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.';
|
||||
}
|
||||
} elseif ($key === 'archive_contract') {
|
||||
$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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,15 @@ public function share(Request $request): array
|
||||
'info' => fn () => $request->session()->get('info'),
|
||||
'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) {
|
||||
try {
|
||||
$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'],
|
||||
'allow_attachments' => ['sometimes', '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'],
|
||||
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
||||
'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'],
|
||||
'allow_attachments' => ['sometimes', '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'],
|
||||
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
||||
'active' => ['nullable', 'boolean'],
|
||||
'signature' => ['nullable', 'array'],
|
||||
'signature.*' => ['nullable', 'string', 'max:1000'],
|
||||
'auto_mailer' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, ',', '.');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Event as DecisionEventModel;
|
||||
use App\Services\DecisionEvents\ConditionEvaluator;
|
||||
use App\Services\DecisionEvents\DecisionEventContext;
|
||||
use App\Services\DecisionEvents\Registry;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -68,6 +69,23 @@ public function handle(): void
|
||||
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);
|
||||
|
||||
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Email;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Services\EmailSender;
|
||||
@@ -53,6 +54,10 @@ public function handle(): void
|
||||
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||
$log->save();
|
||||
|
||||
if ($log->to_email) {
|
||||
Email::query()->where('value', $log->to_email)->update(['failed' => true]);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
|
||||
if ($template && $case) {
|
||||
$note = '';
|
||||
if ($log->status === 'sent') {
|
||||
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||
$note = sprintf('Tel: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||
} elseif ($log->status === 'failed') {
|
||||
$note = sprintf(
|
||||
'Št: %s | Telo: %s | Napaka: %s',
|
||||
'Tel: %s | Telo: %s | Napaka: %s',
|
||||
(string) $this->to,
|
||||
(string) $this->content,
|
||||
'SMS ni bil poslan!'
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
|
||||
class Account extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||
use SoftDeletes;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'reference',
|
||||
@@ -58,6 +59,11 @@ public function payments(): HasMany
|
||||
return $this->hasMany(\App\Models\Payment::class);
|
||||
}
|
||||
|
||||
public function installments(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Installment::class);
|
||||
}
|
||||
|
||||
public function bookings(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Booking::class);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Activity extends Model
|
||||
@@ -18,6 +19,7 @@ class Activity extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'due_date',
|
||||
'call_back_at',
|
||||
'amount',
|
||||
'note',
|
||||
'action_id',
|
||||
@@ -27,6 +29,13 @@ class Activity extends Model
|
||||
'client_case_id',
|
||||
];
|
||||
|
||||
/*protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'call_back_at' => 'datetime',
|
||||
];
|
||||
}*/
|
||||
|
||||
protected $hidden = [
|
||||
'action_id',
|
||||
'decision_id',
|
||||
@@ -146,4 +155,14 @@ public function user(): BelongsTo
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class Email extends Model
|
||||
'is_primary',
|
||||
'is_active',
|
||||
'valid',
|
||||
'failed',
|
||||
'receive_auto_mails',
|
||||
'verified_at',
|
||||
'preferences',
|
||||
@@ -28,6 +29,7 @@ class Email extends Model
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'valid' => 'boolean',
|
||||
'failed' => 'boolean',
|
||||
'receive_auto_mails' => 'boolean',
|
||||
'verified_at' => 'datetime',
|
||||
'preferences' => 'array',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
enum EmailLogStatus: string
|
||||
@@ -83,4 +84,9 @@ public function body(): HasOne
|
||||
{
|
||||
return $this->hasOne(EmailLogBody::class, 'email_log_id');
|
||||
}
|
||||
|
||||
public function activities(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Activity::class, 'activity_email_logs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class EmailTemplate extends Model
|
||||
@@ -19,10 +20,14 @@ class EmailTemplate extends Model
|
||||
'entity_types',
|
||||
'allow_attachments',
|
||||
'active',
|
||||
'action_id',
|
||||
'decision_id',
|
||||
'client',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'client' => 'boolean',
|
||||
'entity_types' => 'array',
|
||||
'allow_attachments' => 'boolean',
|
||||
];
|
||||
@@ -31,4 +36,14 @@ public function documents(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Document::class, 'documentable');
|
||||
}
|
||||
|
||||
public function action(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Action::class);
|
||||
}
|
||||
|
||||
public function decision(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Decision::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -10,13 +10,15 @@ class MailProfile extends Model
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'active', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
|
||||
'reply_to_address', 'reply_to_name', 'priority', 'max_daily_quota', 'emails_sent_today',
|
||||
'name', 'active', 'auto_mailer', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
|
||||
'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',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'auto_mailer' => 'boolean',
|
||||
'signature' => 'array',
|
||||
'last_success_at' => 'datetime',
|
||||
'last_error_at' => 'datetime',
|
||||
'test_checked_at' => 'datetime',
|
||||
|
||||
@@ -34,6 +34,8 @@ public function items()
|
||||
|
||||
public const TYPE_SMS = 'sms';
|
||||
|
||||
public const TYPE_EMAIL = 'email';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
|
||||
@@ -31,6 +31,7 @@ class User extends Authenticatable
|
||||
'email',
|
||||
'password',
|
||||
'active',
|
||||
'login_redirect',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use App\Http\Responses\LoginResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -14,6 +15,7 @@
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
@@ -23,7 +25,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
$recipients = [];
|
||||
if ($client && $client->person) {
|
||||
$recipients = Email::query()
|
||||
$emails = Email::query()
|
||||
->where('person_id', $client->person->id)
|
||||
->where('is_active', 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')
|
||||
->map(fn ($v) => strtolower(trim((string) $v)))
|
||||
->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
|
||||
$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
|
||||
$bodyText = isset($options['body_text']) ? (string) $options['body_text'] : '';
|
||||
$rendered = $this->renderer->render([
|
||||
'subject' => (string) $template->subject_template,
|
||||
'html' => (string) $template->html_template,
|
||||
@@ -89,6 +125,8 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
'person' => $person,
|
||||
'activity' => $activity,
|
||||
'extra' => [],
|
||||
'mail_profile' => $mailProfile,
|
||||
'body_text' => $bodyText,
|
||||
]);
|
||||
|
||||
// Create the log and body
|
||||
@@ -96,7 +134,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
$log->fill([
|
||||
'uuid' => (string) \Str::uuid(),
|
||||
'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(),
|
||||
'to_email' => (string) ($recipients[0] ?? ''),
|
||||
'to_recipients' => $recipients,
|
||||
@@ -136,7 +174,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
|
||||
$log->body()->create([
|
||||
'body_html' => (string) ($rendered['html'] ?? ''),
|
||||
'body_text' => (string) ($rendered['text'] ?? ''),
|
||||
'body_text' => $bodyText !== '' ? $bodyText : (string) ($rendered['text'] ?? ''),
|
||||
'inline_css' => true,
|
||||
]);
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
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()
|
||||
->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);
|
||||
}
|
||||
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +54,7 @@ public function getActivities(
|
||||
int $perPage = 20
|
||||
): LengthAwarePaginator {
|
||||
$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');
|
||||
|
||||
if (! empty($segmentId)) {
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
// 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(
|
||||
$setting,
|
||||
['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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,19 @@ class Registry
|
||||
'add_segment' => AddSegmentHandler::class,
|
||||
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::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
|
||||
{
|
||||
$key = trim(strtolower($key));
|
||||
$class = static::$map[$key] ?? null;
|
||||
if (! $class || ! class_exists($class)) {
|
||||
if (! $class) {
|
||||
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);
|
||||
if (! $handler instanceof DecisionEventHandler) {
|
||||
throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler");
|
||||
|
||||
@@ -152,19 +152,6 @@ public function sendFromLog(EmailLog $log): array
|
||||
$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)) {
|
||||
$email->text($text);
|
||||
}
|
||||
@@ -304,10 +291,6 @@ public function sendFromLog(EmailLog $log): array
|
||||
}
|
||||
|
||||
$mailer->send($email);
|
||||
// Save log if we modified BCC
|
||||
if (! empty($log->getAttribute('bcc'))) {
|
||||
$log->save();
|
||||
}
|
||||
$headers = $email->getHeaders();
|
||||
$messageIdHeader = $headers->get('Message-ID');
|
||||
$messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null;
|
||||
@@ -330,15 +313,6 @@ public function sendFromLog(EmailLog $log): array
|
||||
$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);
|
||||
if (! empty($log->reply_to)) {
|
||||
$message->replyTo($log->reply_to);
|
||||
@@ -464,15 +438,6 @@ public function sendFromLog(EmailLog $log): array
|
||||
$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);
|
||||
if (! empty($log->reply_to)) {
|
||||
$message->replyTo($log->reply_to);
|
||||
|
||||
@@ -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) {
|
||||
$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);
|
||||
};
|
||||
|
||||
$bodyText = isset($ctx['body_text']) ? (string) $ctx['body_text'] : '';
|
||||
|
||||
return [
|
||||
'subject' => $replacer($template['subject']) ?? '',
|
||||
'html' => $replacer($template['html'] ?? null) ?? null,
|
||||
'text' => $replacer($template['text'] ?? null) ?? null,
|
||||
'html' => $this->applyBodyText($replacer($template['html'] ?? null) ?? null, $bodyText, html: true),
|
||||
'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
|
||||
*/
|
||||
@@ -145,12 +177,18 @@ protected function buildMap(array $ctx): array
|
||||
'id' => data_get($co, 'id'),
|
||||
'uuid' => data_get($co, 'uuid'),
|
||||
'reference' => data_get($co, 'reference'),
|
||||
// Format amounts in EU style for emails
|
||||
'amount' => $formatMoneyEu(data_get($co, 'amount')),
|
||||
// Account amounts — sourced from the related Account model
|
||||
'account' => [
|
||||
'balance_amount' => $formatMoneyEu(data_get($co, 'account.balance_amount')),
|
||||
'initial_amount' => $formatMoneyEu(data_get($co, 'account.initial_amount')),
|
||||
],
|
||||
];
|
||||
$meta = data_get($co, 'meta');
|
||||
if (is_string($meta)) {
|
||||
$meta = json_decode($meta, true) ?? [];
|
||||
}
|
||||
if (is_array($meta)) {
|
||||
$out['contract']['meta'] = $meta;
|
||||
$out['contract']['meta'] = $this->flattenMetaForTemplate($meta);
|
||||
}
|
||||
}
|
||||
if (isset($ctx['activity'])) {
|
||||
@@ -172,7 +210,50 @@ protected function buildMap(array $ctx): array
|
||||
if (! empty($ctx['extra']) && is_array($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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1656,6 +1656,10 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
$value = $acc[$field] ?? null;
|
||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
|
||||
$value = $this->normalizeDecimal($value);
|
||||
// Ensure the normalized value is numeric, otherwise default to 0
|
||||
if ($value === '' || $value === '-' || ! is_numeric($value)) {
|
||||
$value = 0;
|
||||
}
|
||||
}
|
||||
// Convert empty string to 0 for amount fields
|
||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
|
||||
@@ -1689,8 +1693,12 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
if ($existing) {
|
||||
// Build non-null changes for account fields
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
// Track balance change
|
||||
$oldBalance = (float) ($existing->balance_amount ?? 0);
|
||||
// Track balance change - normalize in case DB has malformed data
|
||||
$rawBalance = $existing->balance_amount ?? 0;
|
||||
if (is_string($rawBalance) && $rawBalance !== '') {
|
||||
$rawBalance = $this->normalizeDecimal($rawBalance);
|
||||
}
|
||||
$oldBalance = is_numeric($rawBalance) ? (float) $rawBalance : 0;
|
||||
// Note: meta merging for contracts is handled in upsertContractChain, not here
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
@@ -1699,7 +1707,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
||||
|
||||
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
||||
if (array_key_exists('balance_amount', $changes)) {
|
||||
$newBalance = (float) ($existing->balance_amount ?? 0);
|
||||
$rawNewBalance = $existing->balance_amount ?? 0;
|
||||
if (is_string($rawNewBalance) && $rawNewBalance !== '') {
|
||||
$rawNewBalance = $this->normalizeDecimal($rawNewBalance);
|
||||
}
|
||||
$newBalance = is_numeric($rawNewBalance) ? (float) $rawNewBalance : 0;
|
||||
if ($newBalance !== $oldBalance) {
|
||||
try {
|
||||
$contractId = $existing->contract_id;
|
||||
@@ -3194,7 +3206,7 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
||||
->first();*/
|
||||
|
||||
// Build search query combining address, post_code and city
|
||||
$searchParts = [$addrData['post_code']];
|
||||
$searchParts = [$addrData['address']];
|
||||
if (!empty($addrData['post_code'])) {
|
||||
$searchParts[] = $addrData['post_code'];
|
||||
}
|
||||
@@ -3204,7 +3216,7 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
||||
|
||||
$searchQuery = implode(' ', $searchParts);
|
||||
// Use fulltext search (GIN index optimized)
|
||||
$existing = PersonAddress::where('person_id', $personId)
|
||||
$existing = PersonAddress::query()->where('person_id', $personId)
|
||||
->whereRaw("search_vector @@ plainto_tsquery('simple', ?)", [$searchQuery])
|
||||
->first();
|
||||
|
||||
|
||||
+4
-4
@@ -10,21 +10,21 @@
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"diglactic/laravel-breadcrumbs": "^10.0",
|
||||
"http-interop/http-factory-guzzle": "^1.2",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/framework": "12.0",
|
||||
"inertiajs/inertia-laravel": "^3.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/jetstream": "^5.2",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/scout": "^10.11",
|
||||
"laravel/tinker": "^2.9",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"meilisearch/meilisearch-php": "^1.11",
|
||||
"robertboes/inertia-breadcrumbs": "dev-laravel-12",
|
||||
"robertboes/inertia-breadcrumbs": "^1.0",
|
||||
"tightenco/ziggy": "^2.0",
|
||||
"tijsverkoyen/css-to-inline-styles": "^2.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/boost": "^1.1",
|
||||
"laravel/boost": "^2.2",
|
||||
"laravel/pint": "^1.13",
|
||||
"laravel/sail": "^1.26",
|
||||
"mockery/mockery": "^1.6",
|
||||
|
||||
Generated
+1031
-746
File diff suppressed because it is too large
Load Diff
+49
@@ -0,0 +1,49 @@
|
||||
<?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('person_addresses', function (Blueprint $table) {
|
||||
$table->dropIndex('person_addresses_search_vector_idx');
|
||||
$table->dropColumn('search_vector');
|
||||
|
||||
|
||||
$table->string('post_code', 50)->nullable()->change();
|
||||
|
||||
});
|
||||
|
||||
// Add a generated tsvector column for fulltext search
|
||||
DB::statement("
|
||||
ALTER TABLE person_addresses
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
to_tsvector('simple',
|
||||
coalesce(address, '') || ' ' ||
|
||||
coalesce(post_code, '') || ' ' ||
|
||||
coalesce(city, '')
|
||||
)
|
||||
) STORED
|
||||
");
|
||||
|
||||
// Create GIN index on the tsvector column for fast fulltext search
|
||||
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('person_addresses', function (Blueprint $table) {
|
||||
$table->string('post_code', 20)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -31,6 +31,11 @@ public function run(): void
|
||||
'name' => 'End field job',
|
||||
'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) {
|
||||
|
||||
@@ -21,6 +21,7 @@ public function run(): void
|
||||
$this->seedSegmentActivityCountsReport();
|
||||
$this->seedActionsDecisionsCountReport();
|
||||
$this->seedActivitiesPerPeriodReport();
|
||||
$this->seedActivitiesDetailReport();
|
||||
}
|
||||
|
||||
protected function seedActiveContractsReport(): void
|
||||
@@ -783,4 +784,265 @@ protected function seedActivitiesPerPeriodReport(): void
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+42
-138
@@ -46,7 +46,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inertiajs/vue3": "2.0",
|
||||
"@inertiajs/vue3": "^3.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
@@ -952,26 +952,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@inertiajs/core": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.0.17.tgz",
|
||||
"integrity": "sha512-tvYoqiouQSJrP7i7zVq61yyuEjlL96UU4nkkOWtOajXZlubGN4XrgRpnygpDk1KBO8V2yBab3oUZm+aZImwTHg==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.0.3.tgz",
|
||||
"integrity": "sha512-/4sW/cfNpvujjVOZlB5UNypLGNySs7X7V8IMLNSK8+3j1KsUYGS5wpLd9EqAu8wy8RiW7PPra2rPwB6Lx/ACow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
"es-toolkit": "^1.34.1",
|
||||
"qs": "^6.9.0"
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"es-toolkit": "^1.33.0",
|
||||
"laravel-precognition": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"axios": "^1.13.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"axios": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inertiajs/vue3": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.0.17.tgz",
|
||||
"integrity": "sha512-Al0IMHQSj5aTQBLUAkljFEMCw4YRwSiOSKzN8LAbvJpKwvJFgc/wSj3wVVpr/AO9y9mz1w2mtvjnDoOzsntPLw==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-3.0.3.tgz",
|
||||
"integrity": "sha512-bhJN+GS66g1tYH1p6flKkG1N8oaT5J7ZLqBkavN9mHC6bVfoQCUG6sCuA07WTDfo9tDaxU89wsSSAf4mhn3SuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inertiajs/core": "2.0.17",
|
||||
"es-toolkit": "^1.33.0"
|
||||
"@inertiajs/core": "3.0.3",
|
||||
"es-toolkit": "^1.33.0",
|
||||
"laravel-precognition": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
@@ -3804,9 +3813,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -4372,6 +4381,24 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
|
||||
@@ -4875,19 +4902,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||
@@ -5098,22 +5112,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||
@@ -5361,82 +5359,6 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz",
|
||||
@@ -6029,24 +5951,6 @@
|
||||
"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": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inertiajs/vue3": "2.0",
|
||||
"@inertiajs/vue3": "^3.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Pure">
|
||||
<directory>tests/Pure</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@tanstack/vue-table";
|
||||
import { valueUpdater } from "@/lib/utils";
|
||||
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
|
||||
import DataTablePagination from "./DataTablePagination.vue";
|
||||
import DataTablePaginationClient from "./DataTablePaginationClient.vue";
|
||||
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
||||
import DataTableToolbar from "./DataTableToolbar.vue";
|
||||
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
|
||||
@@ -618,7 +618,14 @@ defineExpose({
|
||||
|
||||
<!-- Client-side pagination -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ const props = defineProps({
|
||||
showGoto: { type: Boolean, default: true },
|
||||
maxPageLinks: { type: Number, default: 5 },
|
||||
perPage: { type: Number, default: 10 },
|
||||
table: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:page"]);
|
||||
@@ -34,7 +35,7 @@ function goToPageInput() {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return;
|
||||
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 = "";
|
||||
}
|
||||
|
||||
@@ -136,14 +137,17 @@ function setPage(p) {
|
||||
>
|
||||
<PaginationContent>
|
||||
<!-- First -->
|
||||
<PaginationFirst :disabled="currentPage <= 1" @click="setPage(1)">
|
||||
<PaginationFirst
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.setPageIndex(0)"
|
||||
>
|
||||
<ChevronsLeft />
|
||||
</PaginationFirst>
|
||||
|
||||
<!-- Previous -->
|
||||
<PaginationPrevious
|
||||
:disabled="currentPage <= 1"
|
||||
@click="setPage(currentPage - 1)"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</PaginationPrevious>
|
||||
@@ -154,25 +158,22 @@ function setPage(p) {
|
||||
<PaginationItem
|
||||
v-else
|
||||
:value="item"
|
||||
:is-active="currentPage === item"
|
||||
@click="setPage(item)"
|
||||
:is-active="currentPage === index"
|
||||
@click="table.setPageIndex(index)"
|
||||
>
|
||||
{{ item }}
|
||||
</PaginationItem>
|
||||
</template>
|
||||
|
||||
<!-- Next -->
|
||||
<PaginationNext
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="setPage(currentPage + 1)"
|
||||
>
|
||||
<PaginationNext :disabled="!table.getCanNextPage()" @click="table.nextPage()">
|
||||
<ChevronRight />
|
||||
</PaginationNext>
|
||||
|
||||
<!-- Last -->
|
||||
<PaginationLast
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="setPage(lastPage)"
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||
>
|
||||
<ChevronsRight />
|
||||
</PaginationLast>
|
||||
@@ -191,7 +192,7 @@ function setPage(p) {
|
||||
:max="lastPage"
|
||||
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"
|
||||
:placeholder="String(currentPage)"
|
||||
:placeholder="String(currentPage + 1)"
|
||||
aria-label="Pojdi na stran"
|
||||
@keyup.enter="goToPageInput"
|
||||
@blur="goToPageInput"
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
import { computed, ref, useAttrs } from "vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Calendar } from "@/Components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/Components/ui/popover";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarIcon } from "lucide-vue-next";
|
||||
import { format } from "date-fns";
|
||||
@@ -86,7 +82,9 @@ const toCalendarDate = (value) => {
|
||||
// Convert CalendarDate to ISO string (YYYY-MM-DD)
|
||||
const fromCalendarDate = (calendarDate) => {
|
||||
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({
|
||||
@@ -142,11 +140,10 @@ const open = ref(false);
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="start">
|
||||
<Calendar v-model="calendarDate" :disabled="disabled" />
|
||||
<Calendar locale="si-SI" v-model="calendarDate" :disabled="disabled" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p v-if="error" class="mt-1 text-sm text-red-600">
|
||||
{{ Array.isArray(error) ? error[0] : error }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -6,34 +6,40 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faTrashCan, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ref, watch } from 'vue';
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faTrashCan, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Izbriši' },
|
||||
message: { type: String, default: 'Ali ste prepričani, da želite izbrisati ta element?' },
|
||||
confirmText: { type: String, default: 'Izbriši' },
|
||||
cancelText: { type: String, default: 'Prekliči' },
|
||||
title: { type: String, default: "Izbriši" },
|
||||
message: {
|
||||
type: String,
|
||||
default: "Ali ste prepričani, da želite izbrisati ta element?",
|
||||
},
|
||||
confirmText: { type: String, default: "Izbriši" },
|
||||
cancelText: { type: String, default: "Prekliči" },
|
||||
processing: { type: Boolean, default: false },
|
||||
itemName: { type: String, default: null }, // Optional name to show in confirmation
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'close', 'confirm']);
|
||||
const emit = defineEmits(["update:show", "close", "confirm"]);
|
||||
|
||||
const open = ref(props.show);
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
open.value = newVal;
|
||||
});
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
open.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
watch(open, (newVal) => {
|
||||
emit('update:show', newVal);
|
||||
emit("update:show", newVal);
|
||||
if (!newVal) {
|
||||
emit('close');
|
||||
emit("close");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,7 +48,7 @@ const onClose = () => {
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
emit('confirm');
|
||||
emit("confirm");
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -59,8 +65,13 @@ const onConfirm = () => {
|
||||
<DialogDescription>
|
||||
<div class="flex items-start gap-4 pt-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-6 w-6 text-red-600" />
|
||||
<div
|
||||
class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faTriangleExclamation"
|
||||
class="h-6 w-6 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
@@ -70,9 +81,7 @@ const onConfirm = () => {
|
||||
<p v-if="itemName" class="text-sm font-medium text-gray-900">
|
||||
{{ itemName }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Ta dejanje ni mogoče razveljaviti.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">Ta dejanje ni mogoče razveljaviti.</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
@@ -82,15 +91,10 @@ const onConfirm = () => {
|
||||
<Button variant="outline" @click="onClose" :disabled="processing">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="onConfirm"
|
||||
:disabled="processing"
|
||||
>
|
||||
<Button variant="destructive" @click="onConfirm" :disabled="processing">
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
<script setup>
|
||||
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { ref, watch } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { FormControl, FormField, 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 { Switch } from '@/Components/ui/switch'
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { ref, watch } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
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 { Switch } from "@/Components/ui/switch";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
@@ -17,112 +29,128 @@ const props = defineProps({
|
||||
// Optional list of contracts to allow attaching the document directly to a contract
|
||||
// Each item should have at least: { uuid, reference }
|
||||
contracts: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close', 'uploaded'])
|
||||
});
|
||||
const emit = defineEmits(["close", "uploaded"]);
|
||||
|
||||
const MAX_SIZE = 25 * 1024 * 1024 // 25MB
|
||||
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png']
|
||||
const MAX_SIZE = 25 * 1024 * 1024; // 25MB
|
||||
const ALLOWED_EXTS = [
|
||||
"doc",
|
||||
"docx",
|
||||
"pdf",
|
||||
"txt",
|
||||
"csv",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png",
|
||||
];
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
name: z.string().min(1, 'Ime je obvezno'),
|
||||
description: z.string().optional(),
|
||||
file: z.instanceof(File).refine((file) => file.size > 0, 'Izberite datoteko'),
|
||||
is_public: z.boolean().default(true),
|
||||
contract_uuid: z.string().nullable().optional(),
|
||||
}))
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
name: z.string().min(1, "Ime je obvezno"),
|
||||
description: z.string().optional(),
|
||||
file: z.instanceof(File).refine((file) => file.size > 0, "Izberite datoteko"),
|
||||
is_public: z.boolean().default(true),
|
||||
contract_uuid: z.string().nullable().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
name: "",
|
||||
description: "",
|
||||
file: null,
|
||||
is_public: true,
|
||||
contract_uuid: null,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const localError = ref('')
|
||||
const localError = ref("");
|
||||
|
||||
watch(() => props.show, (v) => {
|
||||
if (!v) return
|
||||
localError.value = ''
|
||||
form.resetForm()
|
||||
})
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return;
|
||||
localError.value = "";
|
||||
form.resetForm();
|
||||
}
|
||||
);
|
||||
|
||||
const onFileChange = (e) => {
|
||||
localError.value = ''
|
||||
const f = e.target.files?.[0]
|
||||
localError.value = "";
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) {
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
form.setFieldValue("file", null);
|
||||
return;
|
||||
}
|
||||
const ext = (f.name.split('.').pop() || '').toLowerCase()
|
||||
const ext = (f.name.split(".").pop() || "").toLowerCase();
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
|
||||
e.target.value = ''
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
|
||||
e.target.value = "";
|
||||
form.setFieldValue("file", null);
|
||||
return;
|
||||
}
|
||||
if (f.size > MAX_SIZE) {
|
||||
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
|
||||
e.target.value = ''
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
|
||||
e.target.value = "";
|
||||
form.setFieldValue("file", null);
|
||||
return;
|
||||
}
|
||||
form.setFieldValue('file', f)
|
||||
form.setFieldValue("file", f);
|
||||
if (!form.values.name) {
|
||||
form.setFieldValue('name', f.name.replace(/\.[^.]+$/, ''))
|
||||
form.setFieldValue("name", f.name.replace(/\.[^.]+$/, ""));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submit = form.handleSubmit(async (values) => {
|
||||
localError.value = ''
|
||||
localError.value = "";
|
||||
if (!values.file) {
|
||||
localError.value = 'Prosimo izberite datoteko.'
|
||||
return
|
||||
localError.value = "Prosimo izberite datoteko.";
|
||||
return;
|
||||
}
|
||||
const ext = (values.file.name.split('.').pop() || '').toLowerCase()
|
||||
const ext = (values.file.name.split(".").pop() || "").toLowerCase();
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
|
||||
return
|
||||
localError.value = "Nepodprta vrsta datoteke. Dovoljeno: " + ALLOWED_EXTS.join(", ");
|
||||
return;
|
||||
}
|
||||
if (values.file.size > MAX_SIZE) {
|
||||
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
|
||||
return
|
||||
localError.value = "Datoteka je prevelika. Največja velikost je 25MB.";
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('name', values.name)
|
||||
formData.append('description', values.description || '')
|
||||
formData.append('file', values.file)
|
||||
formData.append('is_public', values.is_public ? '1' : '0')
|
||||
const formData = new FormData();
|
||||
formData.append("name", values.name);
|
||||
formData.append("description", values.description || "");
|
||||
formData.append("file", values.file);
|
||||
formData.append("is_public", values.is_public ? "1" : "0");
|
||||
if (values.contract_uuid) {
|
||||
formData.append('contract_uuid', values.contract_uuid)
|
||||
formData.append("contract_uuid", values.contract_uuid);
|
||||
}
|
||||
|
||||
router.post(props.postUrl, formData, {
|
||||
forceFormData: true,
|
||||
onSuccess: () => {
|
||||
emit('uploaded')
|
||||
emit('close')
|
||||
form.resetForm()
|
||||
emit("uploaded");
|
||||
emit("close");
|
||||
form.resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Set form errors if any
|
||||
if (errors.name) form.setFieldError('name', errors.name)
|
||||
if (errors.description) form.setFieldError('description', errors.description)
|
||||
if (errors.file) form.setFieldError('file', errors.file)
|
||||
if (errors.contract_uuid) form.setFieldError('contract_uuid', errors.contract_uuid)
|
||||
if (errors.name) form.setFieldError("name", errors.name);
|
||||
if (errors.description) form.setFieldError("description", errors.description);
|
||||
if (errors.file) form.setFieldError("file", errors.file);
|
||||
if (errors.contract_uuid) form.setFieldError("contract_uuid", errors.contract_uuid);
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const close = () => emit('close')
|
||||
const close = () => emit("close");
|
||||
|
||||
const onConfirm = () => {
|
||||
submit()
|
||||
}
|
||||
submit();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -137,7 +165,11 @@ const onConfirm = () => {
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<FormField v-if="props.contracts && props.contracts.length" v-slot="{ value, handleChange }" name="contract_uuid">
|
||||
<FormField
|
||||
v-if="props.contracts && props.contracts.length"
|
||||
v-slot="{ value, handleChange }"
|
||||
name="contract_uuid"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>Pripiši k</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
@@ -148,11 +180,7 @@ const onConfirm = () => {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Primer</SelectItem>
|
||||
<SelectItem
|
||||
v-for="c in props.contracts"
|
||||
:key="c.uuid"
|
||||
:value="c.uuid"
|
||||
>
|
||||
<SelectItem v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">
|
||||
Pogodba: {{ c.reference }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -165,7 +193,11 @@ const onConfirm = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Ime</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="doc_name" v-bind="componentField" />
|
||||
<Input
|
||||
id="doc_name"
|
||||
v-bind="componentField"
|
||||
class="w-full max-w-full overflow-hidden text-ellipsis"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -184,29 +216,24 @@ const onConfirm = () => {
|
||||
<FormField v-slot="{ value, handleChange }" name="file">
|
||||
<FormItem>
|
||||
<FormLabel>Datoteka (max 25MB)</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl class="flex w-full">
|
||||
<Input
|
||||
id="doc_file"
|
||||
type="file"
|
||||
@change="onFileChange"
|
||||
accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png"
|
||||
class="min-w-0 w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
|
||||
<div v-if="value" class="text-sm text-gray-600 mt-1">
|
||||
Izbrana datoteka: {{ value.name }} ({{ (value.size / 1024).toFixed(2) }} KB)
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="is_public">
|
||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
<Switch :model-value="value" @update:model-value="handleChange" />
|
||||
</FormControl>
|
||||
<div class="space-y-1 leading-none">
|
||||
<FormLabel>Javno</FormLabel>
|
||||
|
||||
@@ -1,30 +1,398 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from "vue";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog'
|
||||
import { Button } from '@/Components/ui/button'
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
src: { type: String, default: '' },
|
||||
title: { type: String, default: 'Dokument' }
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
src: { type: String, default: "" },
|
||||
title: { type: String, default: "Dokument" },
|
||||
mimeType: { type: String, default: "" },
|
||||
filename: { type: String, default: "" },
|
||||
});
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const textContent = ref("");
|
||||
const loading = ref(false);
|
||||
const previewGenerating = ref(false);
|
||||
const previewError = ref("");
|
||||
|
||||
// Image viewer – zoom & pan state
|
||||
const containerRef = ref(null);
|
||||
const imageRef = ref(null);
|
||||
const imageScale = ref(1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
const fitScale = ref(1);
|
||||
const isDragging = ref(false);
|
||||
const hasMoved = ref(false);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const dragStartTX = ref(0);
|
||||
const dragStartTY = ref(0);
|
||||
|
||||
const MAX_SCALE = 8;
|
||||
|
||||
const imageCursorClass = computed(() => {
|
||||
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
|
||||
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
|
||||
return "cursor-default";
|
||||
});
|
||||
|
||||
const initImageView = () => {
|
||||
const container = containerRef.value;
|
||||
const img = imageRef.value;
|
||||
if (!container || !img) return;
|
||||
const cW = container.clientWidth;
|
||||
const cH = container.clientHeight;
|
||||
const iW = img.naturalWidth || cW;
|
||||
const iH = img.naturalHeight || cH;
|
||||
const fs = Math.min(1, cW / iW, cH / iH);
|
||||
fitScale.value = fs;
|
||||
imageScale.value = fs;
|
||||
translateX.value = (cW - iW * fs) / 2;
|
||||
translateY.value = (cH - iH * fs) / 2;
|
||||
};
|
||||
|
||||
const resetImageView = () => {
|
||||
initImageView();
|
||||
};
|
||||
|
||||
const clampTranslate = (tx, ty, scale) => {
|
||||
const container = containerRef.value;
|
||||
const img = imageRef.value;
|
||||
if (!container || !img) return { tx, ty };
|
||||
const cW = container.clientWidth;
|
||||
const cH = container.clientHeight;
|
||||
const iW = img.naturalWidth * scale;
|
||||
const iH = img.naturalHeight * scale;
|
||||
// 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(() => {
|
||||
if (props.filename) {
|
||||
return props.filename.split(".").pop()?.toLowerCase() || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const viewerType = computed(() => {
|
||||
const ext = fileExtension.value;
|
||||
const mime = props.mimeType.toLowerCase();
|
||||
|
||||
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
||||
// DOCX/DOC files are converted to PDF by backend - treat as PDF viewer
|
||||
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
|
||||
return "docx";
|
||||
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext) || mime.startsWith("image/"))
|
||||
return "image";
|
||||
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
|
||||
|
||||
return "unsupported";
|
||||
});
|
||||
|
||||
const loadTextContent = async () => {
|
||||
if (!props.src || viewerType.value !== "text") return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.get(props.src);
|
||||
textContent.value = response.data;
|
||||
} catch (e) {
|
||||
textContent.value = "Napaka pri nalaganju vsebine.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// For DOCX files, the backend converts to PDF. If the preview isn't ready yet (202 status),
|
||||
// we poll until it's available.
|
||||
const docxPreviewUrl = ref("");
|
||||
const loadDocxPreview = async () => {
|
||||
if (!props.src || viewerType.value !== "docx") return;
|
||||
|
||||
previewGenerating.value = true;
|
||||
previewError.value = "";
|
||||
docxPreviewUrl.value = "";
|
||||
|
||||
const maxRetries = 15;
|
||||
const retryDelay = 2000; // 2 seconds between retries
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await axios.head(props.src, { validateStatus: () => true });
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
// Preview is ready
|
||||
docxPreviewUrl.value = props.src;
|
||||
previewGenerating.value = false;
|
||||
return;
|
||||
} else if (response.status === 202) {
|
||||
// Preview is being generated, wait and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
} else {
|
||||
// Other error
|
||||
previewError.value = "Napaka pri nalaganju predogleda.";
|
||||
previewGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
previewError.value = "Napaka pri nalaganju predogleda.";
|
||||
previewGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Max retries reached
|
||||
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
|
||||
previewGenerating.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.show, props.src],
|
||||
([show]) => {
|
||||
if (show && viewerType.value === "text") {
|
||||
loadTextContent();
|
||||
}
|
||||
if (show && viewerType.value === "docx") {
|
||||
loadDocxPreview();
|
||||
}
|
||||
// Reset states when dialog closes
|
||||
if (!show) {
|
||||
previewGenerating.value = false;
|
||||
previewError.value = "";
|
||||
docxPreviewUrl.value = "";
|
||||
imageScale.value = 1;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
fitScale.value = 1;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(open) => !open && $emit('close')">
|
||||
<DialogContent class="max-w-4xl">
|
||||
<DialogContent class="max-w-full xl:max-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ props.title }}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Badge>
|
||||
{{ fileExtension }}
|
||||
</Badge>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="h-[70vh]">
|
||||
<iframe v-if="props.src" :src="props.src" class="w-full h-full rounded border" />
|
||||
|
||||
<div class="h-[70vh] overflow-auto">
|
||||
<!-- PDF Viewer (browser native) -->
|
||||
<template v-if="viewerType === 'pdf' && props.src">
|
||||
<iframe
|
||||
:src="props.src"
|
||||
class="w-full h-full rounded border"
|
||||
type="application/pdf"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- DOCX Viewer (converted to PDF by backend) -->
|
||||
<template v-else-if="viewerType === 'docx'">
|
||||
<!-- Loading/generating state -->
|
||||
<div
|
||||
v-if="previewGenerating"
|
||||
class="flex flex-col items-center justify-center h-full gap-4"
|
||||
>
|
||||
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
|
||||
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
|
||||
</div>
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-else-if="previewError"
|
||||
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||||
>
|
||||
<span>{{ previewError }}</span>
|
||||
<Button as="a" :href="props.src" target="_blank" variant="outline">
|
||||
Prenesi datoteko
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Preview ready -->
|
||||
<iframe
|
||||
v-else-if="docxPreviewUrl"
|
||||
:src="docxPreviewUrl"
|
||||
class="w-full h-full rounded border"
|
||||
type="application/pdf"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Image Viewer -->
|
||||
<template v-else-if="viewerType === 'image' && props.src">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative h-full overflow-hidden select-none"
|
||||
:class="imageCursorClass"
|
||||
@mousedown="handleMouseDown"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<img
|
||||
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>
|
||||
|
||||
<!-- Text/CSV/XML Viewer -->
|
||||
<template v-else-if="viewerType === 'text'">
|
||||
<div v-if="loading" class="flex items-center justify-center h-full">
|
||||
<div class="animate-pulse text-gray-500">Nalaganje...</div>
|
||||
</div>
|
||||
<pre
|
||||
v-else
|
||||
class="p-4 bg-gray-50 dark:bg-gray-900 rounded border text-sm overflow-auto h-full whitespace-pre-wrap wrap-break-word"
|
||||
>{{ textContent }}</pre
|
||||
>
|
||||
</template>
|
||||
|
||||
<!-- Unsupported -->
|
||||
<template v-else-if="viewerType === 'unsupported'">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||||
>
|
||||
<span>Predogled ni na voljo za to vrsto datoteke.</span>
|
||||
<Button as="a" :href="props.src" target="_blank" variant="outline">
|
||||
Prenesi datoteko
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- No source -->
|
||||
<div v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { computed, ref, watch } from "vue";
|
||||
import { useForm, Field as FormField } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/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 UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
@@ -27,12 +28,24 @@ const props = defineProps({
|
||||
|
||||
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
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
|
||||
label: z.string().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: "",
|
||||
label: "",
|
||||
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 close = () => {
|
||||
@@ -57,22 +76,46 @@ const close = () => {
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
limitToDecisions.value = false;
|
||||
form.resetForm({
|
||||
values: {
|
||||
value: "",
|
||||
label: "",
|
||||
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 () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
|
||||
const payload = {
|
||||
...form.values,
|
||||
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
|
||||
};
|
||||
|
||||
router.post(
|
||||
route("person.email.create", props.person),
|
||||
values,
|
||||
payload,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
@@ -98,11 +141,14 @@ const create = async () => {
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
|
||||
const payload = {
|
||||
...form.values,
|
||||
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
|
||||
};
|
||||
|
||||
router.put(
|
||||
route("person.email.update", { person: props.person, email_id: props.id }),
|
||||
values,
|
||||
payload,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
@@ -136,10 +182,15 @@ watch(
|
||||
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
|
||||
const email = list.find((e) => e.id === props.id);
|
||||
if (email) {
|
||||
const existingDecisionIds = (email.preferences?.decision_ids ?? []).map(String);
|
||||
limitToDecisions.value = existingDecisionIds.length > 0;
|
||||
form.setValues({
|
||||
value: email.value ?? email.email ?? email.address ?? "",
|
||||
label: email.label ?? "",
|
||||
receive_auto_mails: !!email.receive_auto_mails,
|
||||
valid: email.valid !== undefined ? !!email.valid : true,
|
||||
failed: !!email.failed,
|
||||
decision_ids: existingDecisionIds,
|
||||
});
|
||||
} else {
|
||||
resetForm();
|
||||
@@ -228,6 +279,58 @@ const onConfirm = () => {
|
||||
</div>
|
||||
</FormItem>
|
||||
</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>
|
||||
</form>
|
||||
</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>{{body_text}}</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";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
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({
|
||||
person: Object,
|
||||
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 : []);
|
||||
|
||||
@@ -44,7 +46,17 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
<Button variant="ghost" size="icon" title="Možnosti">
|
||||
<EllipsisVertical />
|
||||
@@ -66,11 +78,28 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 || "-" }}
|
||||
<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
|
||||
v-if="email?.note"
|
||||
|
||||
@@ -30,6 +30,7 @@ import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
|
||||
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
|
||||
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
|
||||
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
|
||||
import PersonInfoEmailDialog from "./PersonInfoEmailDialog.vue";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -58,6 +59,9 @@ const props = defineProps({
|
||||
smsProfiles: { type: Array, default: () => [] },
|
||||
smsSenders: { type: Array, default: () => [] },
|
||||
smsTemplates: { type: Array, default: () => [] },
|
||||
enableEmail: { type: Boolean, default: false },
|
||||
emailTemplates: { type: Array, default: () => [] },
|
||||
mailProfiles: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
// Dialog states
|
||||
@@ -91,6 +95,10 @@ const confirm = ref({
|
||||
const showSmsDialog = ref(false);
|
||||
const smsTargetPhone = ref(null);
|
||||
|
||||
// Email dialog state
|
||||
const showEmailDialog = ref(false);
|
||||
const emailTarget = ref(null);
|
||||
|
||||
// Person handlers
|
||||
const openDrawerUpdateClient = () => {
|
||||
drawerUpdatePerson.value = true;
|
||||
@@ -251,6 +259,18 @@ const closeSmsDialog = () => {
|
||||
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
|
||||
const handlePersonEdit = () => openDrawerUpdateClient();
|
||||
|
||||
@@ -266,6 +286,7 @@ const handlePhoneSms = (phone) => openSmsDialog(phone);
|
||||
const handleEmailAdd = () => openDrawerAddEmail(false, 0);
|
||||
const handleEmailEdit = (id) => openDrawerAddEmail(true, id);
|
||||
const handleEmailDelete = (id, label) => openConfirm("email", id, label);
|
||||
const handleEmailSend = (email) => openEmailDialog(email);
|
||||
|
||||
const handleTrrAdd = () => openDrawerAddTrr(false, 0);
|
||||
const handleTrrEdit = (id) => openDrawerAddTrr(true, id);
|
||||
@@ -299,7 +320,7 @@ const switchToTab = (tab) => {
|
||||
|
||||
<template>
|
||||
<Tabs v-model="activeTab" class="mt-2">
|
||||
<TabsList class="flex w-full bg-white gap-2 p-1">
|
||||
<TabsList class="flex flex-row flex-wrap bg-white gap-2 p-1">
|
||||
<TabsTrigger
|
||||
value="person"
|
||||
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2"
|
||||
@@ -418,9 +439,11 @@ const switchToTab = (tab) => {
|
||||
<PersonInfoEmailsTab
|
||||
:person="person"
|
||||
:edit="edit"
|
||||
:enable-email="enableEmail && !!clientCaseUuid"
|
||||
@add="handleEmailAdd"
|
||||
@edit="handleEmailEdit"
|
||||
@delete="handleEmailDelete"
|
||||
@email="handleEmailSend"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -534,4 +557,15 @@ const switchToTab = (tab) => {
|
||||
:sms-templates="smsTemplates"
|
||||
@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>
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { EllipsisVertical, MessageSquare, MessageSquareText } from "lucide-vue-next";
|
||||
import {
|
||||
CircleCheckBigIcon,
|
||||
CircleCheckIcon,
|
||||
EllipsisVertical,
|
||||
MessageSquare,
|
||||
MessageSquareText,
|
||||
} from "lucide-vue-next";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -79,8 +85,9 @@ const handleSms = (phone) => emit("sms", phone);
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-medium leading-relaxed p-1">
|
||||
<p class="font-medium leading-relaxed p-1 flex gap-1 items-center">
|
||||
{{ phone.nu }}
|
||||
<CircleCheckBigIcon color="#3e9392" size="20" v-if="phone.validated" />
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground p-1" v-if="phone.description">
|
||||
{{ phone.description }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from "vue";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from "@/Components/ui/select";
|
||||
import { Switch } from "@/Components/ui/switch";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
@@ -301,27 +303,13 @@ const updateSmsFromSelection = async () => {
|
||||
const url = route("clientCase.sms.preview", {
|
||||
client_case: props.clientCaseUuid,
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"X-CSRF-TOKEN":
|
||||
document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ||
|
||||
"",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
template_id: form.values.template_id,
|
||||
contract_uuid: form.values.contract_uuid || null,
|
||||
}),
|
||||
credentials: "same-origin",
|
||||
const { data } = await axios.post(url, {
|
||||
template_id: form.values.template_id,
|
||||
contract_uuid: form.values.contract_uuid || null,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (typeof data?.content === "string" && data.content.trim() !== "") {
|
||||
form.setFieldValue("message", data.content);
|
||||
return;
|
||||
}
|
||||
if (typeof data?.content === "string" && data.content.trim() !== "") {
|
||||
form.setFieldValue("message", data.content);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore and fallback
|
||||
@@ -465,11 +453,57 @@ const open = computed({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="onSubmit" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField v-slot="{ value, handleChange }" name="profile_id">
|
||||
<ScrollArea class="max-h-[65vh] pr-1">
|
||||
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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>
|
||||
<FormLabel>Profil</FormLabel>
|
||||
<FormLabel>Pogodba</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -478,18 +512,22 @@ const open = computed({
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="p in pageSmsProfiles" :key="p.id" :value="p.id">
|
||||
{{ p.name || "Profil #" + p.id }}
|
||||
<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">
|
||||
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in
|
||||
{account.*} mest.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="sender_id">
|
||||
<FormField v-slot="{ value, handleChange }" name="template_id">
|
||||
<FormItem>
|
||||
<FormLabel>Pošiljatelj</FormLabel>
|
||||
<FormLabel>Predloga</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -498,125 +536,77 @@ const open = computed({
|
||||
</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 v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
|
||||
{{ t.name || "Predloga #" + t.id }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
|
||||
<FormItem>
|
||||
<FormLabel>Pogodba</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormField v-slot="{ componentField }" name="message">
|
||||
<FormItem>
|
||||
<FormLabel>Vsebina sporočila</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
<Textarea
|
||||
rows="4"
|
||||
placeholder="Vpišite SMS vsebino..."
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</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">
|
||||
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in
|
||||
{account.*} mest.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="template_id">
|
||||
<FormItem>
|
||||
<FormLabel>Predloga</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
|
||||
{{ t.name || "Predloga #" + t.id }}
|
||||
</SelectItem>
|
||||
</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 (UCS‑2). 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 GSM‑7 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 (GSM‑7) oziroma
|
||||
320 (UCS‑2) 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>
|
||||
<!-- 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>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</form>
|
||||
<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 (UCS‑2). 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 GSM‑7 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 (GSM‑7) oziroma
|
||||
320 (UCS‑2) 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>
|
||||
<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));
|
||||
return found?.label || v;
|
||||
});
|
||||
if (labels.length <= 3) return labels.join(', ');
|
||||
const firstThree = labels.slice(0, 3).join(', ');
|
||||
if (labels.length <= 3) return labels.join(", ");
|
||||
const firstThree = labels.slice(0, 3).join(", ");
|
||||
const remaining = labels.length - 3;
|
||||
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
|
||||
});
|
||||
@@ -154,7 +154,7 @@ const summaryText = computed(() => {
|
||||
:variant="chipVariant"
|
||||
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 }}
|
||||
</span>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import { CalendarIcon, XIcon } from "lucide-vue-next";
|
||||
import { computed, ref } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
import { RangeCalendar } from "@/Components/ui/range-calendar";
|
||||
import {
|
||||
DateFormatter,
|
||||
getLocalTimeZone,
|
||||
today,
|
||||
parseDate,
|
||||
CalendarDate,
|
||||
} from "@internationalized/date";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({ start: null, end: null }),
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "Izberi datumski obseg",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
buttonClass: {
|
||||
type: String,
|
||||
default: "w-[280px]",
|
||||
},
|
||||
locale: {
|
||||
type: String,
|
||||
default: "sl-SI",
|
||||
},
|
||||
numberOfMonths: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
minValue: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
maxValue: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const df = new DateFormatter(props.locale, {
|
||||
dateStyle: "medium",
|
||||
});
|
||||
|
||||
// Check if there's a selected value
|
||||
const hasValue = computed(() => {
|
||||
const val = props.modelValue;
|
||||
return val?.start || val?.end;
|
||||
});
|
||||
|
||||
// Convert string dates to CalendarDate objects for the calendar
|
||||
const calendarValue = computed({
|
||||
get() {
|
||||
const val = props.modelValue;
|
||||
if (!val) return undefined;
|
||||
|
||||
let start = null;
|
||||
let end = null;
|
||||
|
||||
if (val.start) {
|
||||
if (typeof val.start === "string") {
|
||||
start = parseDate(val.start);
|
||||
} else if (val.start instanceof CalendarDate) {
|
||||
start = val.start;
|
||||
}
|
||||
}
|
||||
|
||||
if (val.end) {
|
||||
if (typeof val.end === "string") {
|
||||
end = parseDate(val.end);
|
||||
} else if (val.end instanceof CalendarDate) {
|
||||
end = val.end;
|
||||
}
|
||||
}
|
||||
|
||||
if (!start && !end) return undefined;
|
||||
return { start, end };
|
||||
},
|
||||
set(newValue) {
|
||||
if (!newValue) {
|
||||
emit("update:modelValue", { start: null, end: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert CalendarDate to ISO string (YYYY-MM-DD) for easier handling
|
||||
const result = {
|
||||
start: newValue.start ? newValue.start.toString() : null,
|
||||
end: newValue.end ? newValue.end.toString() : null,
|
||||
};
|
||||
emit("update:modelValue", result);
|
||||
|
||||
// Close popover when both dates are selected
|
||||
if (result.start && result.end) {
|
||||
open.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const displayText = computed(() => {
|
||||
const val = calendarValue.value;
|
||||
if (!val?.start) return props.placeholder;
|
||||
|
||||
const startFormatted = df.format(val.start.toDate(getLocalTimeZone()));
|
||||
if (!val.end) return startFormatted;
|
||||
|
||||
const endFormatted = df.format(val.end.toDate(getLocalTimeZone()));
|
||||
return `${startFormatted} - ${endFormatted}`;
|
||||
});
|
||||
|
||||
function clearValue(event) {
|
||||
event.stopPropagation();
|
||||
emit("update:modelValue", { start: null, end: null });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
cn(
|
||||
'justify-start text-left font-normal',
|
||||
!calendarValue?.start && 'text-muted-foreground',
|
||||
buttonClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4 shrink-0" />
|
||||
<span class="truncate flex-1">{{ displayText }}</span>
|
||||
<span
|
||||
v-if="clearable && hasValue && !disabled"
|
||||
class="ml-2 shrink-0 opacity-50 hover:opacity-100 cursor-pointer"
|
||||
@click.stop.prevent="clearValue"
|
||||
>
|
||||
<XIcon class="h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="start">
|
||||
<RangeCalendar
|
||||
v-model="calendarValue"
|
||||
:locale="locale"
|
||||
:number-of-months="numberOfMonths"
|
||||
:min-value="minValue"
|
||||
:max-value="maxValue"
|
||||
initial-focus
|
||||
@update:start-value="
|
||||
(startDate) => {
|
||||
if (calendarValue?.start?.toString() !== startDate?.toString()) {
|
||||
calendarValue = { start: startDate, end: undefined };
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -26,7 +26,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
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 />
|
||||
</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,
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
InboxIcon,
|
||||
AtSignIcon,
|
||||
BookUserIcon,
|
||||
MessageSquareIcon,
|
||||
ArrowLeftIcon,
|
||||
} from "lucide-vue-next";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
@@ -211,13 +210,6 @@ const navGroups = computed(() => [
|
||||
icon: Settings2Icon,
|
||||
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"],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -26,6 +26,9 @@ import { SettingsIcon } from "lucide-vue-next";
|
||||
import { ShieldUserIcon } from "lucide-vue-next";
|
||||
import { SmartphoneIcon } 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({
|
||||
title: String,
|
||||
@@ -157,6 +160,13 @@ const rawMenuGroups = [
|
||||
routeName: "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",
|
||||
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)
|
||||
// Only shown if current user has admin role or manage-settings permission.
|
||||
// We'll filter it out below if not authorized.
|
||||
@@ -268,6 +285,14 @@ function isActive(patterns) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getBadge(item) {
|
||||
if (item.key === "call-laters") {
|
||||
return page.props.callLaterCount || 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -341,11 +366,18 @@ function isActive(patterns) {
|
||||
<!-- Title -->
|
||||
<span
|
||||
v-if="!sidebarCollapsed"
|
||||
class="truncate transition-opacity"
|
||||
class="flex-1 truncate transition-opacity"
|
||||
:class="{ 'font-medium': isActive(item.active) }"
|
||||
>
|
||||
{{ item.title }}
|
||||
</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>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -308,7 +308,7 @@ const closeSearch = () => (searchOpen.value = false);
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<Breadcrumbs
|
||||
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
|
||||
|
||||
@@ -64,6 +64,7 @@ import "quill/dist/quill.snow.css";
|
||||
|
||||
const props = defineProps({
|
||||
template: { type: Object, default: null },
|
||||
actions: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
@@ -75,6 +76,9 @@ const form = useForm({
|
||||
entity_types: props.template?.entity_types ?? ["client", "contract"],
|
||||
allow_attachments: props.template?.allow_attachments ?? false,
|
||||
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: "" });
|
||||
@@ -732,7 +736,8 @@ const placeholderGroups = computed(() => {
|
||||
"contract.id",
|
||||
"contract.uuid",
|
||||
"contract.reference",
|
||||
"contract.amount",
|
||||
"contract.account.balance_amount",
|
||||
"contract.account.initial_amount",
|
||||
"contract.meta.some_key",
|
||||
]);
|
||||
}
|
||||
@@ -747,6 +752,13 @@ const placeholderGroups = computed(() => {
|
||||
]);
|
||||
// Extra is always useful for ad-hoc data
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -1028,6 +1040,49 @@ watch(
|
||||
/>
|
||||
<Label for="active" class="font-normal cursor-pointer">Aktivno</Label>
|
||||
</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>
|
||||
|
||||
<Separator />
|
||||
@@ -1223,6 +1278,25 @@ watch(
|
||||
</Button>
|
||||
</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">{{ body_text }}</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>
|
||||
</CardContent>
|
||||
|
||||
@@ -107,12 +107,6 @@ const cards = [
|
||||
route: "admin.sms-logs.index",
|
||||
icon: InboxIcon,
|
||||
},
|
||||
{
|
||||
title: "SMS paketi",
|
||||
description: "Kreiranje in pošiljanje serijskih SMS paketov",
|
||||
route: "admin.packages.index",
|
||||
icon: MessageSquareIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PencilIcon,
|
||||
SendIcon,
|
||||
MoreVerticalIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
Card,
|
||||
@@ -62,6 +63,32 @@ const createOpen = ref(false); // create modal
|
||||
const editOpen = ref(false); // edit modal
|
||||
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({
|
||||
name: "",
|
||||
host: "",
|
||||
@@ -72,10 +99,12 @@ const form = useForm({
|
||||
from_address: "",
|
||||
from_name: "",
|
||||
priority: 10,
|
||||
auto_mailer: false,
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
form.reset();
|
||||
signatureItems.value = [{ key: "", value: "" }];
|
||||
createOpen.value = true;
|
||||
editTarget.value = null;
|
||||
}
|
||||
@@ -92,7 +121,9 @@ function openEdit(p) {
|
||||
form.from_address = p.from_address || "";
|
||||
form.from_name = p.from_name || "";
|
||||
form.priority = p.priority ?? 10;
|
||||
form.auto_mailer = p.auto_mailer ?? false;
|
||||
editTarget.value = p;
|
||||
signatureItems.value = signatureFromObject(p.signature);
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -102,12 +133,14 @@ function closeCreate() {
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
form.post(route("admin.mail-profiles.store"), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
createOpen.value = false;
|
||||
},
|
||||
});
|
||||
form
|
||||
.transform((data) => ({ ...data, signature: signatureToObject() }))
|
||||
.post(route("admin.mail-profiles.store"), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
createOpen.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
@@ -128,6 +161,8 @@ function submitEdit() {
|
||||
from_address: form.from_address,
|
||||
from_name: form.from_name || null,
|
||||
priority: form.priority,
|
||||
auto_mailer: form.auto_mailer,
|
||||
signature: signatureToObject(),
|
||||
};
|
||||
if (form.password && form.password.trim() !== "") {
|
||||
payload.password = form.password.trim();
|
||||
@@ -149,6 +184,12 @@ function toggleActive(p) {
|
||||
.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) {
|
||||
window.axios
|
||||
.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">Enc</TableHead>
|
||||
<TableHead class="text-center">Aktivno</TableHead>
|
||||
<TableHead class="text-center">Auto-mailer</TableHead>
|
||||
<TableHead class="text-center">Status</TableHead>
|
||||
<TableHead>Zadnji uspeh</TableHead>
|
||||
<TableHead>Napaka</TableHead>
|
||||
@@ -229,6 +271,12 @@ const statusClass = (p) => {
|
||||
@update:model-value="() => toggleActive(p)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Switch
|
||||
:default-value="p.auto_mailer"
|
||||
@update:model-value="() => toggleAutoMailer(p)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge
|
||||
v-if="p.test_status === 'success'"
|
||||
@@ -350,6 +398,51 @@ const statusClass = (p) => {
|
||||
<Label for="create-priority">Prioriteta</Label>
|
||||
<Input id="create-priority" v-model.number="form.priority" type="number" />
|
||||
</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>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
@@ -418,6 +511,51 @@ const statusClass = (p) => {
|
||||
<Label for="edit-priority">Prioriteta</Label>
|
||||
<Input id="edit-priority" v-model.number="form.priority" type="number" />
|
||||
</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>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Pusti geslo prazno, če želiš obdržati obstoječe.
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { useForm, Link } from "@inertiajs/vue3";
|
||||
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 { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
@@ -36,12 +42,16 @@ function submit() {
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Uredi dovoljenje</CardTitle>
|
||||
<CardDescription>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription>
|
||||
<CardDescription
|
||||
>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" as-child>
|
||||
@@ -53,7 +63,6 @@ function submit() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-6">
|
||||
<div class="grid sm:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
@@ -86,16 +95,19 @@ function submit() {
|
||||
class="flex items-center gap-2 text-sm cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
:value="r.id"
|
||||
:checked="form.roles.includes(r.id)"
|
||||
@update:checked="(checked) => {
|
||||
if (checked) form.roles.push(r.id)
|
||||
else form.roles = form.roles.filter(id => id !== r.id)
|
||||
}"
|
||||
:default-value="form.roles.includes(r.id)"
|
||||
@update:model-value="
|
||||
(checked) => {
|
||||
if (checked) form.roles.push(r.id);
|
||||
else form.roles = form.roles.filter((id) => id !== r.id);
|
||||
}
|
||||
"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user