Compare commits
33 Commits
b1c531bb70
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f8f019408a | |||
| ea9376c713 | |||
| 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 |
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreInstallmentRequest;
|
||||||
|
use App\Models\Account;
|
||||||
|
use App\Models\Activity;
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\Installment;
|
||||||
|
use App\Models\InstallmentSetting;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class AccountInstallmentController extends Controller
|
||||||
|
{
|
||||||
|
public function list(Account $account): JsonResponse
|
||||||
|
{
|
||||||
|
$installments = Installment::query()
|
||||||
|
->where('account_id', $account->id)
|
||||||
|
->orderByDesc('installment_at')
|
||||||
|
->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'installment_at', 'created_at'])
|
||||||
|
->map(function (Installment $i) {
|
||||||
|
return [
|
||||||
|
'id' => $i->id,
|
||||||
|
'amount' => (float) $i->amount,
|
||||||
|
'balance_before' => (float) ($i->balance_before ?? 0),
|
||||||
|
'currency' => $i->currency,
|
||||||
|
'reference' => $i->reference,
|
||||||
|
'installment_at' => optional($i->installment_at)?->toDateString(),
|
||||||
|
'created_at' => optional($i->created_at)?->toDateTimeString(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'account' => [
|
||||||
|
'id' => $account->id,
|
||||||
|
'balance_amount' => $account->balance_amount,
|
||||||
|
],
|
||||||
|
'installments' => $installments,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreInstallmentRequest $request, Account $account): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
$amountCents = (int) round(((float) $validated['amount']) * 100);
|
||||||
|
|
||||||
|
$settings = InstallmentSetting::query()->first();
|
||||||
|
$defaultCurrency = strtoupper($settings->default_currency ?? 'EUR');
|
||||||
|
|
||||||
|
$installment = Installment::query()->create([
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'balance_before' => (float) ($account->balance_amount ?? 0),
|
||||||
|
'amount' => (float) $validated['amount'],
|
||||||
|
'currency' => strtoupper($validated['currency'] ?? $defaultCurrency),
|
||||||
|
'reference' => $validated['reference'] ?? null,
|
||||||
|
'installment_at' => $validated['installment_at'] ?? now(),
|
||||||
|
'meta' => $validated['meta'] ?? null,
|
||||||
|
'created_by' => $request->user()?->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Debit booking — increases the account balance
|
||||||
|
Booking::query()->create([
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'payment_id' => null,
|
||||||
|
'amount_cents' => $amountCents,
|
||||||
|
'type' => 'debit',
|
||||||
|
'description' => $installment->reference ? ('Obremenitev '.$installment->reference) : 'Obremenitev',
|
||||||
|
'booked_at' => $installment->installment_at ?? now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($settings && ($settings->create_activity_on_installment ?? false)) {
|
||||||
|
$note = $settings->activity_note_template ?? 'Dodan obrok';
|
||||||
|
$note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $installment->currency], $note);
|
||||||
|
|
||||||
|
$account->refresh();
|
||||||
|
$beforeStr = number_format((float) ($installment->balance_before ?? 0), 2, ',', '.').' '.$installment->currency;
|
||||||
|
$afterStr = number_format((float) ($account->balance_amount ?? 0), 2, ',', '.').' '.$installment->currency;
|
||||||
|
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: obrok)";
|
||||||
|
|
||||||
|
$account->loadMissing('contract');
|
||||||
|
$clientCaseId = $account->contract?->client_case_id;
|
||||||
|
if ($clientCaseId) {
|
||||||
|
$activity = Activity::query()->create([
|
||||||
|
'due_date' => null,
|
||||||
|
'amount' => $amountCents / 100,
|
||||||
|
'note' => $note,
|
||||||
|
'action_id' => $settings->default_action_id,
|
||||||
|
'decision_id' => $settings->default_decision_id,
|
||||||
|
'client_case_id' => $clientCaseId,
|
||||||
|
'contract_id' => $account->contract_id,
|
||||||
|
]);
|
||||||
|
$installment->update(['activity_id' => $activity->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Installment created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Account $account, Installment $installment): RedirectResponse|JsonResponse
|
||||||
|
{
|
||||||
|
if ($installment->account_id !== $account->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete related debit booking(s) to revert balance via model events
|
||||||
|
Booking::query()
|
||||||
|
->where('account_id', $account->id)
|
||||||
|
->where('type', 'debit')
|
||||||
|
->whereDate('booked_at', optional($installment->installment_at)?->toDateString())
|
||||||
|
->where('amount_cents', (int) round(((float) $installment->amount) * 100))
|
||||||
|
->whereNull('payment_id')
|
||||||
|
->get()
|
||||||
|
->each->delete();
|
||||||
|
|
||||||
|
if ($installment->activity_id) {
|
||||||
|
$activity = Activity::query()->find($installment->activity_id);
|
||||||
|
if ($activity) {
|
||||||
|
$activity->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$installment->delete();
|
||||||
|
|
||||||
|
if (request()->wantsJson()) {
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Installment deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
use App\Models\EmailLog;
|
use App\Models\EmailLog;
|
||||||
use App\Models\EmailTemplate;
|
use App\Models\EmailTemplate;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -69,4 +70,15 @@ public function show(EmailLog $emailLog): Response
|
|||||||
'log' => $emailLog,
|
'log' => $emailLog,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function body(EmailLog $emailLog): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', EmailTemplate::class);
|
||||||
|
|
||||||
|
$emailLog->load('body');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'html' => $emailLog->body?->body_html ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
use App\Models\EmailLog;
|
use App\Models\EmailLog;
|
||||||
use App\Models\EmailLogStatus;
|
use App\Models\EmailLogStatus;
|
||||||
use App\Models\EmailTemplate;
|
use App\Models\EmailTemplate;
|
||||||
|
use App\Models\MailProfile;
|
||||||
use App\Services\EmailTemplateRenderer;
|
use App\Services\EmailTemplateRenderer;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -55,8 +56,14 @@ public function create(): Response
|
|||||||
{
|
{
|
||||||
$this->authorize('create', EmailTemplate::class);
|
$this->authorize('create', EmailTemplate::class);
|
||||||
|
|
||||||
|
$actions = \App\Models\Action::query()
|
||||||
|
->with(['decisions:id,name'])
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
|
||||||
return Inertia::render('Admin/EmailTemplates/Edit', [
|
return Inertia::render('Admin/EmailTemplates/Edit', [
|
||||||
'template' => null,
|
'template' => null,
|
||||||
|
'actions' => $actions,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +100,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
|||||||
// Context resolution (shared logic with renderFinalHtml)
|
// Context resolution (shared logic with renderFinalHtml)
|
||||||
$ctx = [];
|
$ctx = [];
|
||||||
if ($id = $request->integer('activity_id')) {
|
if ($id = $request->integer('activity_id')) {
|
||||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||||
if ($activity) {
|
if ($activity) {
|
||||||
$ctx['activity'] = $activity;
|
$ctx['activity'] = $activity;
|
||||||
// Derive base entities from activity when not explicitly provided
|
// Derive base entities from activity when not explicitly provided
|
||||||
@@ -110,7 +117,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($id = $request->integer('contract_id')) {
|
if ($id = $request->integer('contract_id')) {
|
||||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
|
||||||
if ($contract) {
|
if ($contract) {
|
||||||
$ctx['contract'] = $contract;
|
$ctx['contract'] = $contract;
|
||||||
if ($contract->clientCase) {
|
if ($contract->clientCase) {
|
||||||
@@ -140,6 +147,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$ctx['extra'] = (array) $request->input('extra', []);
|
$ctx['extra'] = (array) $request->input('extra', []);
|
||||||
|
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||||
|
|
||||||
$rendered = $renderer->render([
|
$rendered = $renderer->render([
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
@@ -161,8 +169,14 @@ public function edit(EmailTemplate $emailTemplate): Response
|
|||||||
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
|
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
|
$actions = \App\Models\Action::query()
|
||||||
|
->with(['decisions:id,name'])
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
|
||||||
return Inertia::render('Admin/EmailTemplates/Edit', [
|
return Inertia::render('Admin/EmailTemplates/Edit', [
|
||||||
'template' => $emailTemplate,
|
'template' => $emailTemplate,
|
||||||
|
'actions' => $actions,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +195,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
|||||||
// Context resolution
|
// Context resolution
|
||||||
$ctx = [];
|
$ctx = [];
|
||||||
if ($id = $request->integer('activity_id')) {
|
if ($id = $request->integer('activity_id')) {
|
||||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||||
if ($activity) {
|
if ($activity) {
|
||||||
$ctx['activity'] = $activity;
|
$ctx['activity'] = $activity;
|
||||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||||
@@ -197,7 +211,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($id = $request->integer('contract_id')) {
|
if ($id = $request->integer('contract_id')) {
|
||||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
|
||||||
if ($contract) {
|
if ($contract) {
|
||||||
$ctx['contract'] = $contract;
|
$ctx['contract'] = $contract;
|
||||||
if ($contract->clientCase) {
|
if ($contract->clientCase) {
|
||||||
@@ -227,6 +241,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$ctx['extra'] = (array) $request->input('extra', []);
|
$ctx['extra'] = (array) $request->input('extra', []);
|
||||||
|
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||||
|
|
||||||
// Render preview values; we store a minimal snapshot on the log
|
// Render preview values; we store a minimal snapshot on the log
|
||||||
$rendered = $renderer->render([
|
$rendered = $renderer->render([
|
||||||
@@ -293,7 +308,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
|||||||
// Context resolution (same as sendTest)
|
// Context resolution (same as sendTest)
|
||||||
$ctx = [];
|
$ctx = [];
|
||||||
if ($id = $request->integer('activity_id')) {
|
if ($id = $request->integer('activity_id')) {
|
||||||
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
|
||||||
if ($activity) {
|
if ($activity) {
|
||||||
$ctx['activity'] = $activity;
|
$ctx['activity'] = $activity;
|
||||||
if ($activity->contract && ! isset($ctx['contract'])) {
|
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||||
@@ -309,7 +324,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($id = $request->integer('contract_id')) {
|
if ($id = $request->integer('contract_id')) {
|
||||||
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
|
||||||
if ($contract) {
|
if ($contract) {
|
||||||
$ctx['contract'] = $contract;
|
$ctx['contract'] = $contract;
|
||||||
if ($contract->clientCase) {
|
if ($contract->clientCase) {
|
||||||
@@ -339,6 +354,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$ctx['extra'] = (array) $request->input('extra', []);
|
$ctx['extra'] = (array) $request->input('extra', []);
|
||||||
|
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
|
||||||
|
|
||||||
$rendered = $renderer->render([
|
$rendered = $renderer->render([
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public function index(): Response
|
|||||||
->orderBy('priority')
|
->orderBy('priority')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get([
|
->get([
|
||||||
'id', 'name', 'active', 'host', 'port', 'encryption', 'from_address', 'priority', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
|
'id', 'name', 'active', 'auto_mailer', 'host', 'port', 'username', 'from_name', 'encryption', 'from_address', 'priority', 'signature', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Inertia::render('Admin/MailProfiles/Index', [
|
return Inertia::render('Admin/MailProfiles/Index', [
|
||||||
@@ -76,6 +76,15 @@ public function toggle(Request $request, MailProfile $mailProfile)
|
|||||||
return back()->with('success', 'Status updated');
|
return back()->with('success', 'Status updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toggleAutoMailer(Request $request, MailProfile $mailProfile)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $mailProfile);
|
||||||
|
$mailProfile->auto_mailer = ! $mailProfile->auto_mailer;
|
||||||
|
$mailProfile->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Auto-mailer updated');
|
||||||
|
}
|
||||||
|
|
||||||
public function test(Request $request, MailProfile $mailProfile)
|
public function test(Request $request, MailProfile $mailProfile)
|
||||||
{
|
{
|
||||||
$this->authorize('test', $mailProfile);
|
$this->authorize('test', $mailProfile);
|
||||||
|
|||||||
@@ -3,16 +3,18 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreEmailPackageFromContractsRequest;
|
||||||
use App\Http\Requests\StorePackageFromContractsRequest;
|
use App\Http\Requests\StorePackageFromContractsRequest;
|
||||||
use App\Http\Requests\StorePackageRequest;
|
use App\Http\Requests\StorePackageRequest;
|
||||||
|
use App\Jobs\PackageItemEmailJob;
|
||||||
use App\Jobs\PackageItemSmsJob;
|
use App\Jobs\PackageItemSmsJob;
|
||||||
use App\Models\Contract;
|
use App\Models\Contract;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\PackageItem;
|
use App\Models\PackageItem;
|
||||||
use App\Models\SmsTemplate;
|
use App\Models\SmsTemplate;
|
||||||
|
use App\Services\Contact\EmailSelector;
|
||||||
use App\Services\Contact\PhoneSelector;
|
use App\Services\Contact\PhoneSelector;
|
||||||
use App\Services\Sms\SmsService;
|
use App\Services\Sms\SmsService;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
@@ -22,20 +24,40 @@
|
|||||||
|
|
||||||
class PackageController extends Controller
|
class PackageController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): Response
|
public function landing(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Packages/Index');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function smsIndex(Request $request): Response
|
||||||
{
|
{
|
||||||
$perPage = $request->input('per_page') ?? 25;
|
$perPage = $request->input('per_page') ?? 25;
|
||||||
|
|
||||||
$packages = Package::query()
|
$packages = Package::query()
|
||||||
|
->where('type', Package::TYPE_SMS)
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|
||||||
return Inertia::render('Admin/Packages/Index', [
|
return Inertia::render('Packages/Sms/Index', [
|
||||||
'packages' => $packages,
|
'packages' => $packages,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(Request $request): Response
|
public function emailIndex(Request $request): Response
|
||||||
|
{
|
||||||
|
$perPage = $request->input('per_page') ?? 25;
|
||||||
|
|
||||||
|
$packages = Package::query()
|
||||||
|
->where('type', Package::TYPE_EMAIL)
|
||||||
|
->latest('id')
|
||||||
|
->paginate($perPage);
|
||||||
|
|
||||||
|
return Inertia::render('Packages/Mail/Index', [
|
||||||
|
'packages' => $packages,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function smsCreate(Request $request): Response
|
||||||
{
|
{
|
||||||
// Minimal lookups for create form (active only)
|
// Minimal lookups for create form (active only)
|
||||||
$profiles = \App\Models\SmsProfile::query()
|
$profiles = \App\Models\SmsProfile::query()
|
||||||
@@ -70,7 +92,7 @@ public function create(Request $request): Response
|
|||||||
})
|
})
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Admin/Packages/Create', [
|
return Inertia::render('Packages/Sms/Create', [
|
||||||
'profiles' => $profiles,
|
'profiles' => $profiles,
|
||||||
'senders' => $senders,
|
'senders' => $senders,
|
||||||
'templates' => $templates,
|
'templates' => $templates,
|
||||||
@@ -79,7 +101,53 @@ public function create(Request $request): Response
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Package $package, SmsService $sms): Response
|
public function emailCreate(): Response
|
||||||
|
{
|
||||||
|
$emailTemplates = \App\Models\EmailTemplate::query()
|
||||||
|
->where('active', true)
|
||||||
|
->where('client', false)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'subject_template', 'text_template', 'html_template'])
|
||||||
|
->map(fn ($t) => [
|
||||||
|
'id' => $t->id,
|
||||||
|
'name' => $t->name,
|
||||||
|
'subject_template' => $t->subject_template,
|
||||||
|
'text_template' => $t->text_template,
|
||||||
|
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $t->html_template),
|
||||||
|
])->values();
|
||||||
|
$mailProfiles = \App\Models\MailProfile::query()
|
||||||
|
->where('active', true)
|
||||||
|
->orderBy('priority')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
$segments = \App\Models\Segment::query()
|
||||||
|
->where('active', true)
|
||||||
|
->where('exclude', false)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
$clients = \App\Models\Client::query()
|
||||||
|
->with(['person' => function ($q) {
|
||||||
|
$q->select('id', 'uuid', 'full_name');
|
||||||
|
}])
|
||||||
|
->latest('id')
|
||||||
|
->get(['id', 'uuid', 'person_id'])
|
||||||
|
->map(function ($c) {
|
||||||
|
return [
|
||||||
|
'id' => $c->id,
|
||||||
|
'uuid' => $c->uuid,
|
||||||
|
'name' => $c->person?->full_name ?? ('Client #'.$c->id),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return Inertia::render('Packages/Mail/Create', [
|
||||||
|
'emailTemplates' => $emailTemplates,
|
||||||
|
'mailProfiles' => $mailProfiles,
|
||||||
|
'segments' => $segments,
|
||||||
|
'clients' => $clients,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function smsShow(Package $package, SmsService $sms): Response
|
||||||
{
|
{
|
||||||
$items = $package->items()->latest('id')->paginate(25);
|
$items = $package->items()->latest('id')->paginate(25);
|
||||||
|
|
||||||
@@ -213,13 +281,23 @@ public function show(Package $package, SmsService $sms): Response
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Inertia::render('Admin/Packages/Show', [
|
return Inertia::render('Packages/Sms/Show', [
|
||||||
'package' => $package,
|
'package' => $package,
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
'preview' => $preview,
|
'preview' => $preview,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function emailShow(Package $package): Response
|
||||||
|
{
|
||||||
|
$items = $package->items()->latest('id')->paginate(25);
|
||||||
|
|
||||||
|
return Inertia::render('Packages/Mail/Show', [
|
||||||
|
'package' => $package,
|
||||||
|
'items' => $items,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function store(StorePackageRequest $request): RedirectResponse
|
public function store(StorePackageRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
@@ -261,7 +339,11 @@ public function dispatch(Package $package): RedirectResponse
|
|||||||
return back()->with('error', 'Package not in a dispatchable state.');
|
return back()->with('error', 'Package not in a dispatchable state.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) {
|
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) use ($package) {
|
||||||
|
if ($package->type === Package::TYPE_EMAIL) {
|
||||||
|
return new PackageItemEmailJob($item->id);
|
||||||
|
}
|
||||||
|
|
||||||
return new PackageItemSmsJob($item->id);
|
return new PackageItemSmsJob($item->id);
|
||||||
})->all();
|
})->all();
|
||||||
|
|
||||||
@@ -287,7 +369,7 @@ public function dispatch(Package $package): RedirectResponse
|
|||||||
$package->save();
|
$package->save();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
->onQueue('sms')
|
->onQueue($package->type === Package::TYPE_EMAIL ? 'email' : 'sms')
|
||||||
->dispatch();
|
->dispatch();
|
||||||
|
|
||||||
return back()->with('success', 'Package dispatched');
|
return back()->with('success', 'Package dispatched');
|
||||||
@@ -446,6 +528,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
'number' => $phone->nu,
|
'number' => $phone->nu,
|
||||||
'validated' => $phone->validated,
|
'validated' => $phone->validated,
|
||||||
'type' => $phone->phone_type?->value,
|
'type' => $phone->phone_type?->value,
|
||||||
|
'description' => $phone->description,
|
||||||
] : null,
|
] : null,
|
||||||
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
|
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
|
||||||
];
|
];
|
||||||
@@ -542,6 +625,213 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
|
|||||||
return back()->with('success', 'Package created from contracts');
|
return back()->with('success', 'Package created from contracts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List contracts with selected email per person (for email packages).
|
||||||
|
*/
|
||||||
|
public function contractsForEmail(Request $request, EmailSelector $selector): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||||
|
'q' => ['nullable', 'string'],
|
||||||
|
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||||
|
'only_verified' => ['nullable', 'boolean'],
|
||||||
|
'only_with_email' => ['nullable', 'boolean'],
|
||||||
|
'start_date_from' => ['nullable', 'date'],
|
||||||
|
'start_date_to' => ['nullable', 'date'],
|
||||||
|
'promise_date_from' => ['nullable', 'date'],
|
||||||
|
'promise_date_to' => ['nullable', 'date'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||||
|
|
||||||
|
$query = Contract::query()
|
||||||
|
->with([
|
||||||
|
'clientCase.person.emails',
|
||||||
|
'clientCase.client.person',
|
||||||
|
'account',
|
||||||
|
'segments:id,name',
|
||||||
|
])
|
||||||
|
->select('contracts.*')
|
||||||
|
->latest('contracts.id');
|
||||||
|
|
||||||
|
if ($segmentId) {
|
||||||
|
$query->join('contract_segment', function ($j) use ($segmentId) {
|
||||||
|
$j->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||||
|
->where('contract_segment.segment_id', '=', $segmentId)
|
||||||
|
->where('contract_segment.active', true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
|
||||||
|
->from('contract_segment')
|
||||||
|
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
|
||||||
|
->where('contract_segment.active', true)
|
||||||
|
->where('segments.exclude', false)
|
||||||
|
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($q = trim((string) $request->input('q'))) {
|
||||||
|
$query->where('contracts.reference', 'ILIKE', "%{$q}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($clientId = $request->integer('client_id')) {
|
||||||
|
$query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
|
||||||
|
->where('client_cases.client_id', $clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($startDateFrom = $request->input('start_date_from')) {
|
||||||
|
$query->where('contracts.start_date', '>=', $startDateFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($startDateTo = $request->input('start_date_to')) {
|
||||||
|
$query->where('contracts.start_date', '<=', $startDateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$promiseDateFrom = $request->input('promise_date_from');
|
||||||
|
$promiseDateTo = $request->input('promise_date_to');
|
||||||
|
|
||||||
|
if ($promiseDateFrom || $promiseDateTo) {
|
||||||
|
$query->whereHas('account', function ($q) use ($promiseDateFrom, $promiseDateTo) {
|
||||||
|
if ($promiseDateFrom) {
|
||||||
|
$q->where('promise_date', '>=', $promiseDateFrom);
|
||||||
|
}
|
||||||
|
if ($promiseDateTo) {
|
||||||
|
$q->where('promise_date', '<=', $promiseDateTo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->boolean('only_verified')) {
|
||||||
|
$query->whereHas('clientCase.person.emails', function ($q) {
|
||||||
|
$q->where('is_active', true)->whereNotNull('verified_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->boolean('only_with_email')) {
|
||||||
|
$query->whereHas('clientCase.person.emails', function ($q) {
|
||||||
|
$q->where('is_active', true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$contracts = $query->limit(500)->get();
|
||||||
|
|
||||||
|
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||||
|
$person = $contract->clientCase?->person;
|
||||||
|
$selected = $person ? $selector->selectForPerson($person) : ['email' => null, 'reason' => 'no_person'];
|
||||||
|
$email = $selected['email'];
|
||||||
|
$clientPerson = $contract->clientCase?->client?->person;
|
||||||
|
$segment = collect($contract->segments)->last();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $contract->id,
|
||||||
|
'uuid' => $contract->uuid,
|
||||||
|
'reference' => $contract->reference,
|
||||||
|
'start_date' => $contract->start_date,
|
||||||
|
'promise_date' => $contract->account?->promise_date,
|
||||||
|
'case' => [
|
||||||
|
'id' => $contract->clientCase?->id,
|
||||||
|
'uuid' => $contract->clientCase?->uuid,
|
||||||
|
],
|
||||||
|
'person' => [
|
||||||
|
'id' => $person?->id,
|
||||||
|
'uuid' => $person?->uuid,
|
||||||
|
'full_name' => $person?->full_name,
|
||||||
|
],
|
||||||
|
'segment' => $segment,
|
||||||
|
'client' => $clientPerson ? [
|
||||||
|
'id' => $contract->clientCase?->client?->id,
|
||||||
|
'uuid' => $contract->clientCase?->client?->uuid,
|
||||||
|
'name' => $clientPerson->full_name,
|
||||||
|
] : null,
|
||||||
|
'selected_email' => $email ? [
|
||||||
|
'id' => $email->id,
|
||||||
|
'value' => $email->value,
|
||||||
|
'is_primary' => $email->is_primary,
|
||||||
|
'verified' => $email->verified_at !== null,
|
||||||
|
'label' => $email->label,
|
||||||
|
] : null,
|
||||||
|
'no_email_reason' => $email ? null : ($selected['reason'] ?? 'unknown'),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json(['data' => $data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an email package from a list of contracts by selecting recipient emails.
|
||||||
|
*/
|
||||||
|
public function storeEmailFromContracts(StoreEmailPackageFromContractsRequest $request, EmailSelector $selector): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$contracts = Contract::query()
|
||||||
|
->with(['clientCase.person', 'account.type'])
|
||||||
|
->whereIn('id', $data['contract_ids'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($contracts as $contract) {
|
||||||
|
$person = $contract->clientCase?->person;
|
||||||
|
if (! $person) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$selected = $selector->selectForPerson($person);
|
||||||
|
/** @var ?\App\Models\Email $email */
|
||||||
|
$email = $selected['email'];
|
||||||
|
if (! $email) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = [
|
||||||
|
'email' => $email->value,
|
||||||
|
'email_id' => $email->id,
|
||||||
|
'payload' => $data['payload'] ?? [],
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
'account_id' => $contract->account?->id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($items)) {
|
||||||
|
return back()->with('error', 'No recipients found for selected contracts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$package = Package::query()->create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'type' => Package::TYPE_EMAIL,
|
||||||
|
'status' => Package::STATUS_DRAFT,
|
||||||
|
'name' => $data['name'] ?? null,
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'meta' => array_merge($data['meta'] ?? [], [
|
||||||
|
'source' => 'contracts',
|
||||||
|
'skipped' => $skipped,
|
||||||
|
]),
|
||||||
|
'created_by' => optional($request->user())->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$packageItems = collect($items)->map(function (array $row) {
|
||||||
|
return new PackageItem([
|
||||||
|
'status' => 'queued',
|
||||||
|
'target_json' => [
|
||||||
|
'email' => $row['email'],
|
||||||
|
'email_id' => $row['email_id'],
|
||||||
|
'contract_id' => $row['contract_id'] ?? null,
|
||||||
|
'account_id' => $row['account_id'] ?? null,
|
||||||
|
],
|
||||||
|
'payload_json' => $row['payload'] ?? [],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$package->items()->saveMany($packageItems);
|
||||||
|
$package->total_items = $packageItems->count();
|
||||||
|
$package->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Email package created from contracts');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten nested meta structure into dot-notation key-value pairs.
|
* Flatten nested meta structure into dot-notation key-value pairs.
|
||||||
* Extracts 'value' from objects with {title, value, type} structure.
|
* Extracts 'value' from objects with {title, value, type} structure.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public function index(Request $request): Response
|
|||||||
{
|
{
|
||||||
Gate::authorize('manage-settings');
|
Gate::authorize('manage-settings');
|
||||||
|
|
||||||
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active']);
|
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active', 'login_redirect']);
|
||||||
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
|
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
|
||||||
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
|
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
|
||||||
|
|
||||||
@@ -73,4 +73,17 @@ public function toggleActive(User $user): RedirectResponse
|
|||||||
|
|
||||||
return back()->with('success', "Uporabnik {$status}");
|
return back()->with('success', "Uporabnik {$status}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateSettings(Request $request, User $user): RedirectResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('manage-settings');
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'login_redirect' => ['nullable', 'string', 'max:255'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->update($validated);
|
||||||
|
|
||||||
|
return back()->with('success', 'Nastavitve shranjene');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\CallLater;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class CallLaterController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): \Inertia\Response
|
||||||
|
{
|
||||||
|
$query = CallLater::query()
|
||||||
|
->with([
|
||||||
|
'clientCase.person',
|
||||||
|
'contract',
|
||||||
|
'user',
|
||||||
|
'activity',
|
||||||
|
])
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->orderBy('call_back_at', 'asc');
|
||||||
|
|
||||||
|
if ($request->filled('date_from')) {
|
||||||
|
$query->whereDate('call_back_at', '>=', $request->date_from);
|
||||||
|
}
|
||||||
|
if ($request->filled('date_to')) {
|
||||||
|
$query->whereDate('call_back_at', '<=', $request->date_to);
|
||||||
|
}
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$term = '%'.$request->search.'%';
|
||||||
|
$query->whereHas('clientCase.person', function ($q) use ($term) {
|
||||||
|
$q->where('first_name', 'ilike', $term)
|
||||||
|
->orWhere('last_name', 'ilike', $term)
|
||||||
|
->orWhere('full_name', 'ilike', $term)
|
||||||
|
->orWhereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", [$term]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$callLaters = $query->paginate(50)->withQueryString();
|
||||||
|
|
||||||
|
return Inertia::render('CallLaters/Index', [
|
||||||
|
'callLaters' => $callLaters,
|
||||||
|
'filters' => $request->only(['date_from', 'date_to', 'search']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(CallLater $callLater): \Illuminate\Http\RedirectResponse
|
||||||
|
{
|
||||||
|
$callLater->update(['completed_at' => now()]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Klic označen kot opravljen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,10 +71,8 @@ public function index(ClientCase $clientCase, Request $request)
|
|||||||
$que->whereDate('client_cases.created_at', '<=', $to);
|
$que->whereDate('client_cases.created_at', '<=', $to);
|
||||||
})
|
})
|
||||||
->groupBy('client_cases.id')
|
->groupBy('client_cases.id')
|
||||||
->addSelect([
|
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
|
||||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
||||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
|
||||||
])
|
|
||||||
->with(['person.client', 'client.person'])
|
->with(['person.client', 'client.person'])
|
||||||
->orderByDesc('client_cases.created_at');
|
->orderByDesc('client_cases.created_at');
|
||||||
|
|
||||||
@@ -223,7 +221,11 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
|||||||
return back()->with('warning', __('contracts.edit_not_allowed_archived'));
|
return back()->with('warning', __('contracts.edit_not_allowed_archived'));
|
||||||
}
|
}
|
||||||
|
|
||||||
\DB::transaction(function () use ($request, $contract) {
|
$balanceChanged = false;
|
||||||
|
$oldBalance = null;
|
||||||
|
$newBalance = null;
|
||||||
|
|
||||||
|
\DB::transaction(function () use ($request, $contract, &$balanceChanged, &$oldBalance, &$newBalance) {
|
||||||
$contract->update([
|
$contract->update([
|
||||||
'reference' => $request->input('reference'),
|
'reference' => $request->input('reference'),
|
||||||
'type_id' => $request->input('type_id'),
|
'type_id' => $request->input('type_id'),
|
||||||
@@ -254,6 +256,7 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
|||||||
$accountData['type_id'] = $request->input('account_type_id');
|
$accountData['type_id'] = $request->input('account_type_id');
|
||||||
}
|
}
|
||||||
if ($currentAccount) {
|
if ($currentAccount) {
|
||||||
|
$oldBalance = (float) $currentAccount->balance_amount;
|
||||||
$currentAccount->update($accountData);
|
$currentAccount->update($accountData);
|
||||||
if (array_key_exists('balance_amount', $accountData)) {
|
if (array_key_exists('balance_amount', $accountData)) {
|
||||||
$currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save();
|
$currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save();
|
||||||
@@ -264,6 +267,10 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
|||||||
->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]);
|
->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]);
|
||||||
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
||||||
}
|
}
|
||||||
|
$newBalance = $freshBal;
|
||||||
|
if ($oldBalance !== $freshBal) {
|
||||||
|
$balanceChanged = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
||||||
}
|
}
|
||||||
@@ -276,6 +283,27 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fire activity if balance changed and settings require it
|
||||||
|
if ($balanceChanged) {
|
||||||
|
$contractSetting = \App\Models\ContractSetting::query()->first();
|
||||||
|
if ($contractSetting && $contractSetting->create_activity_on_balance_change) {
|
||||||
|
$note = str_replace(
|
||||||
|
['{old_balance}', '{new_balance}', '{currency}'],
|
||||||
|
[number_format($oldBalance, 2, '.', ''), number_format($newBalance, 2, '.', ''), 'EUR'],
|
||||||
|
$contractSetting->activity_note_template ?? ''
|
||||||
|
);
|
||||||
|
\App\Models\Activity::query()->create([
|
||||||
|
'due_date' => null,
|
||||||
|
'amount' => $newBalance,
|
||||||
|
'note' => $note,
|
||||||
|
'action_id' => $contractSetting->default_action_id,
|
||||||
|
'decision_id' => $contractSetting->default_decision_id,
|
||||||
|
'client_case_id' => $contract->client_case_id,
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Preserve segment filter if present
|
// Preserve segment filter if present
|
||||||
$segment = request('segment');
|
$segment = request('segment');
|
||||||
|
|
||||||
@@ -306,6 +334,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
try {
|
try {
|
||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'due_date' => 'nullable|date',
|
'due_date' => 'nullable|date',
|
||||||
|
'call_back_at' => 'nullable|date_format:Y-m-d H:i:s|after_or_equal:now',
|
||||||
'amount' => 'nullable|decimal:0,4',
|
'amount' => 'nullable|decimal:0,4',
|
||||||
'note' => 'nullable|string',
|
'note' => 'nullable|string',
|
||||||
'action_id' => 'exists:\App\Models\Action,id',
|
'action_id' => 'exists:\App\Models\Action,id',
|
||||||
@@ -326,14 +355,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
|
|
||||||
// Determine which contracts to process
|
// Determine which contracts to process
|
||||||
$contractIds = [];
|
$contractIds = [];
|
||||||
if ($createForAll && !empty($contractUuids)) {
|
if ($createForAll && ! empty($contractUuids)) {
|
||||||
// Get all contract IDs from the provided UUIDs
|
// Get all contract IDs from the provided UUIDs
|
||||||
$contracts = Contract::withTrashed()
|
$contracts = Contract::withTrashed()
|
||||||
->whereIn('uuid', $contractUuids)
|
->whereIn('uuid', $contractUuids)
|
||||||
->where('client_case_id', $clientCase->id)
|
->where('client_case_id', $clientCase->id)
|
||||||
->get();
|
->get();
|
||||||
$contractIds = $contracts->pluck('id')->toArray();
|
$contractIds = $contracts->pluck('id')->toArray();
|
||||||
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
|
} elseif (! empty($contractUuids) && isset($contractUuids[0])) {
|
||||||
// Single contract mode
|
// Single contract mode
|
||||||
$contract = Contract::withTrashed()
|
$contract = Contract::withTrashed()
|
||||||
->where('uuid', $contractUuids[0])
|
->where('uuid', $contractUuids[0])
|
||||||
@@ -342,7 +371,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
if ($contract) {
|
if ($contract) {
|
||||||
$contractIds = [$contract->id];
|
$contractIds = [$contract->id];
|
||||||
}
|
}
|
||||||
} elseif (!empty($attributes['contract_uuid'])) {
|
} elseif (! empty($attributes['contract_uuid'])) {
|
||||||
// Legacy single contract_uuid support
|
// Legacy single contract_uuid support
|
||||||
$contract = Contract::withTrashed()
|
$contract = Contract::withTrashed()
|
||||||
->where('uuid', $attributes['contract_uuid'])
|
->where('uuid', $attributes['contract_uuid'])
|
||||||
@@ -371,6 +400,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
// Create activity
|
// Create activity
|
||||||
$row = $clientCase->activities()->create([
|
$row = $clientCase->activities()->create([
|
||||||
'due_date' => $attributes['due_date'] ?? null,
|
'due_date' => $attributes['due_date'] ?? null,
|
||||||
|
'call_back_at' => $attributes['call_back_at'] ?? null,
|
||||||
'amount' => $attributes['amount'] ?? null,
|
'amount' => $attributes['amount'] ?? null,
|
||||||
'note' => $attributes['note'] ?? null,
|
'note' => $attributes['note'] ?? null,
|
||||||
'action_id' => $attributes['action_id'],
|
'action_id' => $attributes['action_id'],
|
||||||
@@ -602,9 +632,9 @@ public function storeDocument(ClientCase $clientCase, Request $request)
|
|||||||
$contract = null;
|
$contract = null;
|
||||||
if (! empty($validated['contract_uuid'])) {
|
if (! empty($validated['contract_uuid'])) {
|
||||||
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
|
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
|
||||||
if ($contract && ! $contract->active) {
|
/*if ($contract && ! $contract->active) {
|
||||||
return back()->with('warning', __('contracts.document_not_allowed_archived'));
|
return back()->with('warning', __('contracts.document_not_allowed_archived'));
|
||||||
}
|
}*/
|
||||||
}
|
}
|
||||||
$directory = $contract
|
$directory = $contract
|
||||||
? ('contracts/'.$contract->uuid.'/documents')
|
? ('contracts/'.$contract->uuid.'/documents')
|
||||||
@@ -825,9 +855,8 @@ public function show(ClientCase $clientCase)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get contracts using service
|
// Get contracts using service
|
||||||
$contractsPerPage = request()->integer('contracts_per_page', 10);
|
$contracts = $this->caseDataService->getContracts($case, $segmentId);
|
||||||
$contracts = $this->caseDataService->getContracts($case, $segmentId, $contractsPerPage);
|
$contractIds = collect($contracts)->pluck('id')->all();
|
||||||
$contractIds = collect($contracts->items())->pluck('id')->all();
|
|
||||||
|
|
||||||
// Get activities using service
|
// Get activities using service
|
||||||
$activitiesPerPage = request()->integer('activities_per_page', 15);
|
$activitiesPerPage = request()->integer('activities_per_page', 15);
|
||||||
@@ -868,11 +897,14 @@ public function show(ClientCase $clientCase)
|
|||||||
'decisions.emailTemplate' => function ($q) {
|
'decisions.emailTemplate' => function ($q) {
|
||||||
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
||||||
},
|
},
|
||||||
|
'decisions.events' => function ($q) {
|
||||||
|
$q->select('events.id', 'events.key', 'events.name');
|
||||||
|
},
|
||||||
])
|
])
|
||||||
->get(['id', 'name', 'color_tag', 'segment_id']),
|
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||||
'types' => $types,
|
'types' => $types,
|
||||||
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
|
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
|
||||||
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
|
'all_segments' => Segment::query()->where('active', true)->get(['id', 'name']),
|
||||||
'current_segment' => $currentSegment,
|
'current_segment' => $currentSegment,
|
||||||
'sms_profiles' => \App\Models\SmsProfile::query()
|
'sms_profiles' => \App\Models\SmsProfile::query()
|
||||||
->select(['id', 'name', 'default_sender_id'])
|
->select(['id', 'name', 'default_sender_id'])
|
||||||
@@ -881,14 +913,27 @@ public function show(ClientCase $clientCase)
|
|||||||
->get(),
|
->get(),
|
||||||
'sms_senders' => \App\Models\SmsSender::query()
|
'sms_senders' => \App\Models\SmsSender::query()
|
||||||
->select(['id', 'profile_id'])
|
->select(['id', 'profile_id'])
|
||||||
->addSelect(\DB::raw('sname as name'))
|
->selectRaw('sname as name')
|
||||||
->addSelect(\DB::raw('phone_number as phone'))
|
->selectRaw('phone_number as phone')
|
||||||
->orderBy('sname')
|
->orderBy('sname')
|
||||||
->get(),
|
->get(),
|
||||||
'sms_templates' => \App\Models\SmsTemplate::query()
|
'sms_templates' => \App\Models\SmsTemplate::query()
|
||||||
->select(['id', 'name', 'content', 'allow_custom_body'])
|
->select(['id', 'name', 'content', 'allow_custom_body'])
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(),
|
->get(),
|
||||||
|
'email_templates' => \App\Models\EmailTemplate::query()
|
||||||
|
->select(['id', 'name', 'subject_template', 'text_template', 'action_id', 'decision_id'])
|
||||||
|
->where('active', true)
|
||||||
|
->where('client', false)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
'mail_profiles' => \App\Models\MailProfile::query()
|
||||||
|
->select(['id', 'name'])
|
||||||
|
->where('active', true)
|
||||||
|
->orderBy('priority')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1102,6 +1147,7 @@ public function archiveBatch(Request $request)
|
|||||||
|
|
||||||
if (! $setting) {
|
if (! $setting) {
|
||||||
\Log::warning('No archive settings found for batch archive');
|
\Log::warning('No archive settings found for batch archive');
|
||||||
|
|
||||||
return back()->with('flash', [
|
return back()->with('flash', [
|
||||||
'error' => 'No archive settings found',
|
'error' => 'No archive settings found',
|
||||||
]);
|
]);
|
||||||
@@ -1117,8 +1163,9 @@ public function archiveBatch(Request $request)
|
|||||||
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
||||||
|
|
||||||
// Skip if contract is already archived (active = 0)
|
// Skip if contract is already archived (active = 0)
|
||||||
if (!$contract->active) {
|
if (! $contract->active) {
|
||||||
$skippedCount++;
|
$skippedCount++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,7 +1255,7 @@ public function archiveBatch(Request $request)
|
|||||||
if ($skippedCount > 0) {
|
if ($skippedCount > 0) {
|
||||||
$message .= ", skipped $skippedCount already archived";
|
$message .= ", skipped $skippedCount already archived";
|
||||||
}
|
}
|
||||||
$message .= ", " . count($errors) . " failed";
|
$message .= ', '.count($errors).' failed';
|
||||||
|
|
||||||
return back()->with('flash', [
|
return back()->with('flash', [
|
||||||
'error' => $message,
|
'error' => $message,
|
||||||
@@ -1345,10 +1392,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
|||||||
if (! empty($validated['sender_id'])) {
|
if (! empty($validated['sender_id'])) {
|
||||||
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
|
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
|
||||||
if (! $sender) {
|
if (! $sender) {
|
||||||
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
||||||
}
|
}
|
||||||
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
|
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
|
||||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (! $profile) {
|
if (! $profile) {
|
||||||
@@ -1391,7 +1438,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create an activity before sending
|
// Create an activity before sending
|
||||||
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
||||||
$activityData = [
|
$activityData = [
|
||||||
'note' => $activityNote,
|
'note' => $activityNote,
|
||||||
'user_id' => optional($request->user())->id,
|
'user_id' => optional($request->user())->id,
|
||||||
@@ -1540,6 +1587,161 @@ public function previewSms(ClientCase $clientCase, Request $request, SmsService
|
|||||||
* Extracts 'value' from objects with {title, value, type} structure.
|
* Extracts 'value' from objects with {title, value, type} structure.
|
||||||
* Also creates direct access aliases for nested fields (skipping numeric keys).
|
* Also creates direct access aliases for nested fields (skipping numeric keys).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Render an email template preview with context from the client case.
|
||||||
|
*/
|
||||||
|
public function previewEmailForEmail(ClientCase $clientCase, Request $request, int $email_id): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'template_id' => ['required', 'integer', 'exists:email_templates,id'],
|
||||||
|
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
|
||||||
|
'body_text' => ['sometimes', 'nullable', 'string', 'max:10000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$email = \App\Models\Email::query()
|
||||||
|
->where('id', $email_id)
|
||||||
|
->where('person_id', $clientCase->person_id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$template = \App\Models\EmailTemplate::findOrFail((int) $validated['template_id']);
|
||||||
|
|
||||||
|
$contract = null;
|
||||||
|
if (! empty($validated['contract_uuid'])) {
|
||||||
|
$contract = $clientCase->contracts()
|
||||||
|
->where('uuid', $validated['contract_uuid'])
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
$ctx = $this->buildCaseEmailContext($clientCase, $contract);
|
||||||
|
$ctx['body_text'] = (string) ($validated['body_text'] ?? '');
|
||||||
|
|
||||||
|
$renderer = app(\App\Services\EmailTemplateRenderer::class);
|
||||||
|
$rendered = $renderer->render([
|
||||||
|
'subject' => (string) $template->subject_template,
|
||||||
|
'html' => (string) $template->html_template,
|
||||||
|
'text' => (string) $template->text_template,
|
||||||
|
], $ctx);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'subject' => $rendered['subject'] ?? '',
|
||||||
|
'html' => (string) ($rendered['html'] ?? ''),
|
||||||
|
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $template->html_template),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a (possibly templated) email to a person email address belonging to this case.
|
||||||
|
*/
|
||||||
|
public function sendEmailToEmail(ClientCase $clientCase, Request $request, int $email_id)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'subject' => ['required', 'string', 'max:255'],
|
||||||
|
'html_body' => ['nullable', 'string'],
|
||||||
|
'body_text' => ['nullable', 'string', 'max:10000'],
|
||||||
|
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:email_templates,id'],
|
||||||
|
'mail_profile_id' => ['sometimes', 'nullable', 'integer', 'exists:mail_profiles,id'],
|
||||||
|
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ensure the email belongs to the person of this case
|
||||||
|
$email = \App\Models\Email::query()
|
||||||
|
->where('id', $email_id)
|
||||||
|
->where('person_id', $clientCase->person_id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$to = (string) $email->value;
|
||||||
|
|
||||||
|
/** @var \App\Models\MailProfile|null $mailProfile */
|
||||||
|
$mailProfile = ! empty($validated['mail_profile_id'])
|
||||||
|
? \App\Models\MailProfile::query()->where('id', $validated['mail_profile_id'])->where('active', true)->first()
|
||||||
|
: \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
|
||||||
|
|
||||||
|
if (! $mailProfile) {
|
||||||
|
return back()->with('error', 'Ni aktivnega e-poštnega profila.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = null;
|
||||||
|
if (! empty($validated['contract_uuid'])) {
|
||||||
|
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
$htmlBody = (string) ($validated['html_body'] ?? '');
|
||||||
|
$bodyText = (string) ($validated['body_text'] ?? '');
|
||||||
|
|
||||||
|
// Apply {{body_text}} substitution if the html body contains the placeholder
|
||||||
|
if ($bodyText !== '' && preg_match('/{{\s*body_text\s*}}/', $htmlBody)) {
|
||||||
|
$renderer = app(\App\Services\EmailTemplateRenderer::class);
|
||||||
|
$htmlBody = $renderer->applyBodyText($htmlBody, $bodyText, html: true) ?? $htmlBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = (string) $validated['subject'];
|
||||||
|
|
||||||
|
$log = new \App\Models\EmailLog;
|
||||||
|
$log->fill([
|
||||||
|
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||||
|
'template_id' => $validated['template_id'] ?? null,
|
||||||
|
'mail_profile_id' => $mailProfile->id,
|
||||||
|
'to_email' => $to,
|
||||||
|
'to_recipients' => [$to],
|
||||||
|
'subject' => $subject,
|
||||||
|
'body_html_hash' => $htmlBody !== '' ? hash('sha256', $htmlBody) : null,
|
||||||
|
'body_text_preview' => null,
|
||||||
|
'embed_mode' => 'base64',
|
||||||
|
'status' => \App\Models\EmailLogStatus::Queued,
|
||||||
|
'queued_at' => now(),
|
||||||
|
'client_id' => $clientCase->client_id,
|
||||||
|
'client_case_id' => $clientCase->id,
|
||||||
|
'contract_id' => $contract?->id,
|
||||||
|
'ip' => $request->ip(),
|
||||||
|
]);
|
||||||
|
$log->save();
|
||||||
|
|
||||||
|
$log->body()->create([
|
||||||
|
'body_html' => $htmlBody,
|
||||||
|
'body_text' => $bodyText,
|
||||||
|
'inline_css' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
dispatch(new \App\Jobs\SendEmailTemplateJob($log->id));
|
||||||
|
|
||||||
|
// Create activity if template has action/decision
|
||||||
|
if (! empty($validated['template_id'])) {
|
||||||
|
$template = \App\Models\EmailTemplate::find((int) $validated['template_id']);
|
||||||
|
if ($template && ($template->action_id || $template->decision_id)) {
|
||||||
|
$activity = $clientCase->activities()->create(array_filter([
|
||||||
|
'contract_id' => $contract?->id,
|
||||||
|
'action_id' => $template->action_id,
|
||||||
|
'decision_id' => $template->decision_id,
|
||||||
|
'note' => 'Poslano: '.$to.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
|
||||||
|
'user_id' => optional($request->user())->id,
|
||||||
|
], fn ($v) => ! is_null($v)));
|
||||||
|
$activity->emailLogs()->attach($log->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', "E-pošta poslana na {$to}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a template rendering context from the given client case and optional contract.
|
||||||
|
*/
|
||||||
|
private function buildCaseEmailContext(ClientCase $clientCase, ?\App\Models\Contract $contract = null): array
|
||||||
|
{
|
||||||
|
$clientCase->loadMissing('client.person');
|
||||||
|
$ctx = [
|
||||||
|
'client_case' => $clientCase,
|
||||||
|
'client' => $clientCase->client,
|
||||||
|
'person' => optional($clientCase->client)->person,
|
||||||
|
'mail_profile' => \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(),
|
||||||
|
];
|
||||||
|
if ($contract) {
|
||||||
|
$contract->loadMissing(['clientCase.client.person', 'account.type']);
|
||||||
|
$ctx['contract'] = $contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ctx;
|
||||||
|
}
|
||||||
|
|
||||||
private function flattenMeta(array $meta, string $prefix = ''): array
|
private function flattenMeta(array $meta, string $prefix = ''): array
|
||||||
{
|
{
|
||||||
$result = [];
|
$result = [];
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public function index(Client $client, Request $request)
|
|||||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||||
->groupBy('clients.id');
|
->groupBy('clients.id');
|
||||||
})
|
})
|
||||||
//->where('clients.active', 1)
|
// ->where('clients.active', 1)
|
||||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||||
->leftJoin('contracts', function ($join) {
|
->leftJoin('contracts', function ($join) {
|
||||||
@@ -40,12 +40,8 @@ public function index(Client $client, Request $request)
|
|||||||
})
|
})
|
||||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||||
->groupBy('clients.id')
|
->groupBy('clients.id')
|
||||||
->addSelect([
|
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count')
|
||||||
// Number of client cases for this client that have at least one active contract
|
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
||||||
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
|
|
||||||
// Sum of account balances for active contracts
|
|
||||||
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
|
||||||
])
|
|
||||||
->with('person')
|
->with('person')
|
||||||
->orderByDesc('clients.created_at');
|
->orderByDesc('clients.created_at');
|
||||||
|
|
||||||
@@ -71,6 +67,7 @@ public function show(Client $client, Request $request)
|
|||||||
|
|
||||||
return Inertia::render('Client/Show', [
|
return Inertia::render('Client/Show', [
|
||||||
'client' => $data,
|
'client' => $data,
|
||||||
|
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||||
'client_cases' => $data->clientCases()
|
'client_cases' => $data->clientCases()
|
||||||
->select('client_cases.*')
|
->select('client_cases.*')
|
||||||
->when($request->input('search'), function ($que, $search) {
|
->when($request->input('search'), function ($que, $search) {
|
||||||
@@ -88,10 +85,8 @@ public function show(Client $client, Request $request)
|
|||||||
})
|
})
|
||||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||||
->groupBy('client_cases.id')
|
->groupBy('client_cases.id')
|
||||||
->addSelect([
|
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
|
||||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
||||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
|
||||||
])
|
|
||||||
->with(['person', 'client.person'])
|
->with(['person', 'client.person'])
|
||||||
->where('client_cases.active', 1)
|
->where('client_cases.active', 1)
|
||||||
->orderByDesc('client_cases.created_at')
|
->orderByDesc('client_cases.created_at')
|
||||||
@@ -162,6 +157,7 @@ public function contracts(Client $client, Request $request)
|
|||||||
|
|
||||||
return Inertia::render('Client/Contracts', [
|
return Inertia::render('Client/Contracts', [
|
||||||
'client' => $data,
|
'client' => $data,
|
||||||
|
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||||
'contracts' => $contractsQuery
|
'contracts' => $contractsQuery
|
||||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
||||||
->withQueryString(),
|
->withQueryString(),
|
||||||
|
|||||||
@@ -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 App\Services\Sms\SmsService;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -80,14 +79,14 @@ public function __invoke(SmsService $sms): Response
|
|||||||
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
||||||
|
|
||||||
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||||
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
->selectRaw("DATE(COALESCE(assigned_at, created_at) AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
|
||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c', 'd');
|
->pluck('c', 'd');
|
||||||
|
|
||||||
// Completed field jobs last 7 days
|
// Completed field jobs last 7 days
|
||||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||||
->whereBetween('completed_at', [$start, $end])
|
->whereBetween('completed_at', [$start, $end])
|
||||||
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
->selectRaw("DATE(completed_at AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
|
||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c', 'd');
|
->pluck('c', 'd');
|
||||||
|
|
||||||
@@ -101,13 +100,13 @@ public function __invoke(SmsService $sms): Response
|
|||||||
// Field jobs assigned today - cached
|
// Field jobs assigned today - cached
|
||||||
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||||
return FieldJob::query()
|
return FieldJob::query()
|
||||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
->whereRaw('DATE(COALESCE(assigned_at, created_at)) = ?', [$today->toDateString()])
|
||||||
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||||
->with(['contract' => function ($q) {
|
->with(['contract' => function ($q) {
|
||||||
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||||
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
||||||
}])
|
}])
|
||||||
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
->orderByRaw('COALESCE(assigned_at, created_at) DESC')
|
||||||
->limit(15)
|
->limit(15)
|
||||||
->get()
|
->get()
|
||||||
->map(function ($fj) {
|
->map(function ($fj) {
|
||||||
@@ -120,20 +119,26 @@ public function __invoke(SmsService $sms): Response
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $contract) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $fj->id,
|
'id' => $fj->id,
|
||||||
'priority' => $fj->priority,
|
'priority' => $fj->priority,
|
||||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||||
'created_at' => $fj->created_at?->toIso8601String(),
|
'created_at' => $fj->created_at?->toIso8601String(),
|
||||||
'contract' => $contract ? [
|
'contract' => [
|
||||||
'uuid' => $contract->uuid,
|
'uuid' => $contract->uuid,
|
||||||
'reference' => $contract->reference,
|
'reference' => $contract->reference,
|
||||||
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||||
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||||
'segment_id' => $segmentId,
|
'segment_id' => $segmentId,
|
||||||
] : null,
|
],
|
||||||
];
|
];
|
||||||
});
|
})
|
||||||
|
->filter()
|
||||||
|
->values();
|
||||||
});
|
});
|
||||||
|
|
||||||
// System health for timestamp
|
// System health for timestamp
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public function index(Request $request)
|
|||||||
'current_page' => $paginator->currentPage(),
|
'current_page' => $paginator->currentPage(),
|
||||||
'from' => $paginator->firstItem(),
|
'from' => $paginator->firstItem(),
|
||||||
'last_page' => $paginator->lastPage(),
|
'last_page' => $paginator->lastPage(),
|
||||||
|
'links' => $paginator->linkCollection()->toArray(),
|
||||||
'path' => $paginator->path(),
|
'path' => $paginator->path(),
|
||||||
'per_page' => $paginator->perPage(),
|
'per_page' => $paginator->perPage(),
|
||||||
'to' => $paginator->lastItem(),
|
'to' => $paginator->lastItem(),
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\UpdateInstallmentSettingRequest;
|
||||||
|
use App\Models\Action;
|
||||||
|
use App\Models\Decision;
|
||||||
|
use App\Models\InstallmentSetting;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class InstallmentSettingController extends Controller
|
||||||
|
{
|
||||||
|
public function edit(): Response
|
||||||
|
{
|
||||||
|
$setting = InstallmentSetting::query()->first();
|
||||||
|
if (! $setting) {
|
||||||
|
$setting = InstallmentSetting::query()->create([
|
||||||
|
'default_currency' => 'EUR',
|
||||||
|
'create_activity_on_installment' => false,
|
||||||
|
'default_decision_id' => null,
|
||||||
|
'default_action_id' => null,
|
||||||
|
'activity_note_template' => 'Dodan obrok: {amount} {currency}',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||||
|
$actions = Action::query()
|
||||||
|
->with(['decisions:id'])
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(function (Action $a) {
|
||||||
|
return [
|
||||||
|
'id' => $a->id,
|
||||||
|
'name' => $a->name,
|
||||||
|
'decision_ids' => $a->decisions->pluck('id')->values(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Settings/Installments/Index', [
|
||||||
|
'setting' => [
|
||||||
|
'id' => $setting->id,
|
||||||
|
'default_currency' => $setting->default_currency,
|
||||||
|
'create_activity_on_installment' => (bool) $setting->create_activity_on_installment,
|
||||||
|
'default_decision_id' => $setting->default_decision_id,
|
||||||
|
'default_action_id' => $setting->default_action_id,
|
||||||
|
'activity_note_template' => $setting->activity_note_template,
|
||||||
|
],
|
||||||
|
'decisions' => $decisions,
|
||||||
|
'actions' => $actions,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateInstallmentSettingRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$setting = InstallmentSetting::query()->firstOrFail();
|
||||||
|
|
||||||
|
$data['create_activity_on_installment'] = (bool) ($data['create_activity_on_installment'] ?? false);
|
||||||
|
|
||||||
|
$setting->update($data);
|
||||||
|
|
||||||
|
return back()->with('success', 'Nastavitve shranjene.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\BankAccount;
|
|
||||||
use App\Models\Person\Person;
|
use App\Models\Person\Person;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -22,14 +21,14 @@ public function update(Person $person, Request $request)
|
|||||||
'tax_number' => 'nullable|integer',
|
'tax_number' => 'nullable|integer',
|
||||||
'social_security_number' => 'nullable|integer',
|
'social_security_number' => 'nullable|integer',
|
||||||
'description' => 'nullable|string|max:500',
|
'description' => 'nullable|string|max:500',
|
||||||
|
'employer' => 'nullable|string|max:255',
|
||||||
|
'birthday' => 'nullable|date',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$person->update($attributes);
|
$person->update($attributes);
|
||||||
|
|
||||||
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createAddress(Person $person, Request $request)
|
public function createAddress(Person $person, Request $request)
|
||||||
@@ -80,7 +79,6 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
|||||||
$address = $person->addresses()->findOrFail($address_id);
|
$address = $person->addresses()->findOrFail($address_id);
|
||||||
$address->delete(); // soft delete
|
$address->delete(); // soft delete
|
||||||
|
|
||||||
|
|
||||||
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,12 +136,19 @@ public function createEmail(Person $person, Request $request)
|
|||||||
'is_primary' => 'boolean',
|
'is_primary' => 'boolean',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'valid' => 'boolean',
|
'valid' => 'boolean',
|
||||||
|
'failed' => 'boolean',
|
||||||
'receive_auto_mails' => 'sometimes|boolean',
|
'receive_auto_mails' => 'sometimes|boolean',
|
||||||
'verified_at' => 'nullable|date',
|
'verified_at' => 'nullable|date',
|
||||||
'preferences' => 'nullable|array',
|
'preferences' => 'nullable|array',
|
||||||
'meta' => 'nullable|array',
|
'meta' => 'nullable|array',
|
||||||
|
'decision_ids' => 'nullable|array',
|
||||||
|
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||||
|
unset($attributes['decision_ids']);
|
||||||
|
$attributes['preferences'] = array_merge($attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||||
|
|
||||||
// Dedup: avoid duplicate email per person by value
|
// Dedup: avoid duplicate email per person by value
|
||||||
$email = $person->emails()->firstOrCreate([
|
$email = $person->emails()->firstOrCreate([
|
||||||
'value' => $attributes['value'],
|
'value' => $attributes['value'],
|
||||||
@@ -160,14 +165,21 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
|||||||
'is_primary' => 'boolean',
|
'is_primary' => 'boolean',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'valid' => 'boolean',
|
'valid' => 'boolean',
|
||||||
|
'failed' => 'boolean',
|
||||||
'receive_auto_mails' => 'sometimes|boolean',
|
'receive_auto_mails' => 'sometimes|boolean',
|
||||||
'verified_at' => 'nullable|date',
|
'verified_at' => 'nullable|date',
|
||||||
'preferences' => 'nullable|array',
|
'preferences' => 'nullable|array',
|
||||||
'meta' => 'nullable|array',
|
'meta' => 'nullable|array',
|
||||||
|
'decision_ids' => 'nullable|array',
|
||||||
|
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$email = $person->emails()->findOrFail($email_id);
|
$email = $person->emails()->findOrFail($email_id);
|
||||||
|
|
||||||
|
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||||
|
unset($attributes['decision_ids']);
|
||||||
|
$attributes['preferences'] = array_merge($email->preferences ?? [], $attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||||
|
|
||||||
$email->update($attributes);
|
$email->update($attributes);
|
||||||
|
|
||||||
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
||||||
@@ -204,10 +216,8 @@ public function createTrr(Person $person, Request $request)
|
|||||||
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||||
$trr = $person->bankAccounts()->create($attributes);
|
$trr = $person->bankAccounts()->create($attributes);
|
||||||
|
|
||||||
|
|
||||||
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateTrr(Person $person, int $trr_id, Request $request)
|
public function updateTrr(Person $person, int $trr_id, Request $request)
|
||||||
@@ -238,7 +248,6 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
|||||||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||||
$trr->delete();
|
$trr->delete();
|
||||||
|
|
||||||
|
|
||||||
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,19 +10,14 @@
|
|||||||
class PhoneViewController extends Controller
|
class PhoneViewController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||||
public function index(Request $request)
|
|
||||||
|
public function index(Request $request): \Inertia\Response
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$userId = $request->user()->id;
|
||||||
$search = $request->input('search');
|
$search = $request->input('search');
|
||||||
$clientFilter = $request->input('client');
|
$clientFilter = $request->input('client');
|
||||||
$perPage = $request->integer('per_page', 15);
|
|
||||||
$perPage = max(1, min(100, $perPage));
|
|
||||||
|
|
||||||
$query = FieldJob::query()
|
$eagerLoad = [
|
||||||
->where('assigned_user_id', $userId)
|
|
||||||
->whereNull('completed_at')
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->with([
|
|
||||||
'contract' => function ($q) {
|
'contract' => function ($q) {
|
||||||
$q->with([
|
$q->with([
|
||||||
'type:id,name',
|
'type:id,name',
|
||||||
@@ -33,19 +28,22 @@ public function index(Request $request)
|
|||||||
'clientCase.client.person:id,full_name',
|
'clientCase.client.person:id,full_name',
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
])
|
];
|
||||||
->orderByDesc('assigned_at');
|
|
||||||
|
$baseQuery = FieldJob::query()
|
||||||
|
->where('assigned_user_id', $userId)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->with($eagerLoad);
|
||||||
|
|
||||||
// Apply client filter
|
|
||||||
if ($clientFilter) {
|
if ($clientFilter) {
|
||||||
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
$baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||||
$q->where('uuid', $clientFilter);
|
$q->where('uuid', $clientFilter);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply search filter
|
|
||||||
if ($search) {
|
if ($search) {
|
||||||
$query->where(function ($q) use ($search) {
|
$baseQuery->where(function ($q) use ($search) {
|
||||||
$q->whereHas('contract', function ($cq) use ($search) {
|
$q->whereHas('contract', function ($cq) use ($search) {
|
||||||
$cq->where('reference', 'ilike', '%'.$search.'%')
|
$cq->where('reference', 'ilike', '%'.$search.'%')
|
||||||
->orWhereHas('clientCase.person', function ($pq) use ($search) {
|
->orWhereHas('clientCase.person', function ($pq) use ($search) {
|
||||||
@@ -58,9 +56,14 @@ public function index(Request $request)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$jobs = $query->paginate($perPage)->withQueryString();
|
$pendingQuery = (clone $baseQuery)
|
||||||
|
->where(fn ($q) => $q->where('added_activity', false)->orWhereNull('added_activity'))
|
||||||
|
->orderByDesc('assigned_at');
|
||||||
|
|
||||||
|
$processedQuery = (clone $baseQuery)
|
||||||
|
->where('added_activity', true)
|
||||||
|
->orderByDesc('assigned_at');
|
||||||
|
|
||||||
// Get unique clients for filter dropdown
|
|
||||||
$clients = \App\Models\Client::query()
|
$clients = \App\Models\Client::query()
|
||||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
|
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
|
||||||
$q->where('assigned_user_id', $userId)
|
$q->where('assigned_user_id', $userId)
|
||||||
@@ -77,7 +80,8 @@ public function index(Request $request)
|
|||||||
->values();
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Phone/Index', [
|
return Inertia::render('Phone/Index', [
|
||||||
'jobs' => $jobs,
|
'pendingJobs' => Inertia::scroll(fn () => $pendingQuery->paginate(15, pageName: 'pending')),
|
||||||
|
'processedJobs' => Inertia::scroll(fn () => $processedQuery->paginate(15, pageName: 'processed')),
|
||||||
'clients' => $clients,
|
'clients' => $clients,
|
||||||
'view_mode' => 'assigned',
|
'view_mode' => 'assigned',
|
||||||
'filters' => [
|
'filters' => [
|
||||||
@@ -87,13 +91,11 @@ public function index(Request $request)
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completedToday(Request $request)
|
public function completedToday(Request $request): \Inertia\Response
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$userId = $request->user()->id;
|
||||||
$search = $request->input('search');
|
$search = $request->input('search');
|
||||||
$clientFilter = $request->input('client');
|
$clientFilter = $request->input('client');
|
||||||
$perPage = $request->integer('per_page', 15);
|
|
||||||
$perPage = max(1, min(100, $perPage));
|
|
||||||
|
|
||||||
$start = now()->startOfDay();
|
$start = now()->startOfDay();
|
||||||
$end = now()->endOfDay();
|
$end = now()->endOfDay();
|
||||||
@@ -138,9 +140,6 @@ public function completedToday(Request $request)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$jobs = $query->paginate($perPage)->withQueryString();
|
|
||||||
|
|
||||||
// Get unique clients for filter dropdown
|
|
||||||
$clients = \App\Models\Client::query()
|
$clients = \App\Models\Client::query()
|
||||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
|
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
|
||||||
$q->where('assigned_user_id', $userId)
|
$q->where('assigned_user_id', $userId)
|
||||||
@@ -157,7 +156,7 @@ public function completedToday(Request $request)
|
|||||||
->values();
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Phone/Index', [
|
return Inertia::render('Phone/Index', [
|
||||||
'jobs' => $jobs,
|
'completedJobs' => Inertia::scroll(fn () => $query->paginate(15, pageName: 'completed')),
|
||||||
'clients' => $clients,
|
'clients' => $clients,
|
||||||
'view_mode' => 'completed-today',
|
'view_mode' => 'completed-today',
|
||||||
'filters' => [
|
'filters' => [
|
||||||
|
|||||||
@@ -279,9 +279,9 @@ public function clients(Request $request)
|
|||||||
$clients = \App\Models\Client::query()
|
$clients = \App\Models\Client::query()
|
||||||
->with('person:id,full_name')
|
->with('person:id,full_name')
|
||||||
->get()
|
->get()
|
||||||
->map(fn($c) => [
|
->map(fn ($c) => [
|
||||||
'id' => $c->uuid,
|
'id' => $c->uuid,
|
||||||
'name' => $c->person->full_name ?? 'Unknown'
|
'name' => $c->person->full_name ?? 'Unknown',
|
||||||
])
|
])
|
||||||
->sortBy('name')
|
->sortBy('name')
|
||||||
->values();
|
->values();
|
||||||
@@ -289,6 +289,41 @@ public function clients(Request $request)
|
|||||||
return response()->json($clients);
|
return response()->json($clients);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight actions lookup for select:action filters.
|
||||||
|
*/
|
||||||
|
public function actions(Request $request)
|
||||||
|
{
|
||||||
|
$actions = \App\Models\Action::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name'])
|
||||||
|
->map(fn ($a) => ['id' => $a->id, 'name' => $a->name])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json($actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight decisions lookup for select:decision filters.
|
||||||
|
* Optionally filtered by action_id (for dependent filter UI).
|
||||||
|
*/
|
||||||
|
public function decisions(Request $request)
|
||||||
|
{
|
||||||
|
$actionId = $request->integer('action_id', 0) ?: null;
|
||||||
|
|
||||||
|
$q = \App\Models\Decision::query()->orderBy('name');
|
||||||
|
|
||||||
|
if ($actionId !== null) {
|
||||||
|
$q->whereHas('actions', fn ($qq) => $qq->where('actions.id', $actionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$decisions = $q->get(['id', 'name'])
|
||||||
|
->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json($decisions);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build validation rules based on inputs descriptor and validate.
|
* Build validation rules based on inputs descriptor and validate.
|
||||||
*
|
*
|
||||||
@@ -307,6 +342,8 @@ protected function validateFilters(array $inputs, Request $request): array
|
|||||||
'integer' => [$nullable, 'integer'],
|
'integer' => [$nullable, 'integer'],
|
||||||
'select:user' => [$nullable, 'integer', 'exists:users,id'],
|
'select:user' => [$nullable, 'integer', 'exists:users,id'],
|
||||||
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
|
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
|
||||||
|
'select:action' => [$nullable, 'integer', 'exists:actions,id'],
|
||||||
|
'select:decision' => [$nullable, 'integer', 'exists:decisions,id'],
|
||||||
default => [$nullable, 'string'],
|
default => [$nullable, 'string'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -319,7 +356,7 @@ protected function validateFilters(array $inputs, Request $request): array
|
|||||||
*/
|
*/
|
||||||
protected function buildInputsArray(Report $report): array
|
protected function buildInputsArray(Report $report): array
|
||||||
{
|
{
|
||||||
return $report->filters->map(fn($filter) => [
|
return $report->filters->map(fn ($filter) => [
|
||||||
'key' => $filter->key,
|
'key' => $filter->key,
|
||||||
'type' => $filter->type,
|
'type' => $filter->type,
|
||||||
'label' => $filter->label,
|
'label' => $filter->label,
|
||||||
@@ -336,7 +373,7 @@ protected function buildColumnsArray(Report $report): array
|
|||||||
{
|
{
|
||||||
return $report->columns
|
return $report->columns
|
||||||
->where('visible', true)
|
->where('visible', true)
|
||||||
->map(fn($col) => [
|
->map(fn ($col) => [
|
||||||
'key' => $col->key,
|
'key' => $col->key,
|
||||||
'label' => $col->label,
|
'label' => $col->label,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
use App\Models\Decision;
|
use App\Models\Decision;
|
||||||
use App\Models\EmailTemplate;
|
use App\Models\EmailTemplate;
|
||||||
use App\Models\Segment;
|
use App\Models\Segment;
|
||||||
|
use App\Services\DecisionEvents\ConditionEvaluator;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -22,6 +23,8 @@ public function index(Request $request)
|
|||||||
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
|
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
|
||||||
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
|
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
|
||||||
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
|
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
|
||||||
|
'condition_fields' => ConditionEvaluator::availableFields(),
|
||||||
|
'condition_operators' => ConditionEvaluator::availableOperators(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +86,9 @@ public function updateAction(int $id, Request $request)
|
|||||||
|
|
||||||
public function storeDecision(Request $request)
|
public function storeDecision(Request $request)
|
||||||
{
|
{
|
||||||
|
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
||||||
|
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
||||||
|
|
||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'color_tag' => 'nullable|string|max:25',
|
'color_tag' => 'nullable|string|max:25',
|
||||||
@@ -96,6 +102,14 @@ public function storeDecision(Request $request)
|
|||||||
'events.*.active' => 'sometimes|boolean',
|
'events.*.active' => 'sometimes|boolean',
|
||||||
'events.*.run_order' => 'nullable|integer',
|
'events.*.run_order' => 'nullable|integer',
|
||||||
'events.*.config' => 'nullable|array',
|
'events.*.config' => 'nullable|array',
|
||||||
|
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
|
||||||
|
'events.*.config.deactivate_previous' => 'sometimes|boolean',
|
||||||
|
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
|
||||||
|
'events.*.config.reactivate' => 'sometimes|boolean',
|
||||||
|
'events.*.config.conditions' => 'nullable|array',
|
||||||
|
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
|
||||||
|
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
|
||||||
|
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||||
@@ -112,12 +126,12 @@ public function storeDecision(Request $request)
|
|||||||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||||
if ($key === 'add_segment') {
|
if ($key === 'add_segment') {
|
||||||
$seg = $ev['config']['segment_id'] ?? null;
|
$seg = $ev['config']['segment_id'] ?? null;
|
||||||
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
if (empty($seg)) {
|
||||||
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||||
}
|
}
|
||||||
} elseif ($key === 'archive_contract') {
|
} elseif ($key === 'archive_contract') {
|
||||||
$as = $ev['config']['archive_setting_id'] ?? null;
|
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||||
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
|
if (empty($as)) {
|
||||||
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +188,9 @@ public function updateDecision(int $id, Request $request)
|
|||||||
{
|
{
|
||||||
$row = Decision::findOrFail($id);
|
$row = Decision::findOrFail($id);
|
||||||
|
|
||||||
|
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
||||||
|
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
||||||
|
|
||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'color_tag' => 'nullable|string|max:25',
|
'color_tag' => 'nullable|string|max:25',
|
||||||
@@ -187,6 +204,14 @@ public function updateDecision(int $id, Request $request)
|
|||||||
'events.*.active' => 'sometimes|boolean',
|
'events.*.active' => 'sometimes|boolean',
|
||||||
'events.*.run_order' => 'nullable|integer',
|
'events.*.run_order' => 'nullable|integer',
|
||||||
'events.*.config' => 'nullable|array',
|
'events.*.config' => 'nullable|array',
|
||||||
|
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
|
||||||
|
'events.*.config.deactivate_previous' => 'sometimes|boolean',
|
||||||
|
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
|
||||||
|
'events.*.config.reactivate' => 'sometimes|boolean',
|
||||||
|
'events.*.config.conditions' => 'nullable|array',
|
||||||
|
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
|
||||||
|
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
|
||||||
|
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||||
@@ -203,12 +228,12 @@ public function updateDecision(int $id, Request $request)
|
|||||||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||||
if ($key === 'add_segment') {
|
if ($key === 'add_segment') {
|
||||||
$seg = $ev['config']['segment_id'] ?? null;
|
$seg = $ev['config']['segment_id'] ?? null;
|
||||||
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
if (empty($seg)) {
|
||||||
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||||
}
|
}
|
||||||
} elseif ($key === 'archive_contract') {
|
} elseif ($key === 'archive_contract') {
|
||||||
$as = $ev['config']['archive_setting_id'] ?? null;
|
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||||
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
|
if (empty($as)) {
|
||||||
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ public function share(Request $request): array
|
|||||||
'info' => fn () => $request->session()->get('info'),
|
'info' => fn () => $request->session()->get('info'),
|
||||||
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
||||||
],
|
],
|
||||||
|
'callLaterCount' => function () use ($request) {
|
||||||
|
if (! $request->user()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return \App\Models\CallLater::query()
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->count();
|
||||||
|
},
|
||||||
'notifications' => function () use ($request) {
|
'notifications' => function () use ($request) {
|
||||||
try {
|
try {
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreEmailPackageFromContractsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage-settings') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => ['required', 'in:email'],
|
||||||
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'meta' => ['nullable', 'array'],
|
||||||
|
|
||||||
|
// Common payload for all items
|
||||||
|
'payload' => ['required', 'array'],
|
||||||
|
'payload.mail_profile_id' => ['nullable', 'integer', 'exists:mail_profiles,id'],
|
||||||
|
'payload.template_id' => ['nullable', 'integer', 'exists:email_templates,id'],
|
||||||
|
'payload.subject' => ['nullable', 'string', 'max:255'],
|
||||||
|
'payload.body_text' => ['nullable', 'string', 'max:10000'],
|
||||||
|
'payload.variables' => ['nullable', 'array'],
|
||||||
|
|
||||||
|
// Source contracts to derive items from
|
||||||
|
'contract_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'contract_ids.*' => ['integer', 'exists:contracts,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ public function rules(): array
|
|||||||
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
||||||
'allow_attachments' => ['sometimes', 'boolean'],
|
'allow_attachments' => ['sometimes', 'boolean'],
|
||||||
'active' => ['boolean'],
|
'active' => ['boolean'],
|
||||||
|
'client' => ['sometimes', 'boolean'],
|
||||||
|
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
|
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreInstallmentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'amount' => ['required', 'numeric', 'min:0.01'],
|
||||||
|
'currency' => ['nullable', 'string', 'size:3'],
|
||||||
|
'reference' => ['nullable', 'string', 'max:100'],
|
||||||
|
'installment_at' => ['nullable', 'date'],
|
||||||
|
'meta' => ['nullable', 'array'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ public function rules(): array
|
|||||||
'reply_to_name' => ['nullable', 'string', 'max:190'],
|
'reply_to_name' => ['nullable', 'string', 'max:190'],
|
||||||
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
||||||
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'signature' => ['nullable', 'array'],
|
||||||
|
'signature.*' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'auto_mailer' => ['nullable', 'boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateContractSettingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'create_activity_on_balance_change' => ['sometimes', 'boolean'],
|
||||||
|
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
|
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
|
'activity_note_template' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ public function rules(): array
|
|||||||
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
||||||
'allow_attachments' => ['sometimes', 'boolean'],
|
'allow_attachments' => ['sometimes', 'boolean'],
|
||||||
'active' => ['boolean'],
|
'active' => ['boolean'],
|
||||||
|
'client' => ['sometimes', 'boolean'],
|
||||||
|
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
|
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateInstallmentSettingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'default_currency' => ['required', 'string', 'size:3'],
|
||||||
|
'create_activity_on_installment' => ['sometimes', 'boolean'],
|
||||||
|
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
|
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
|
'activity_note_template' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ public function rules(): array
|
|||||||
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
||||||
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
||||||
'active' => ['nullable', 'boolean'],
|
'active' => ['nullable', 'boolean'],
|
||||||
|
'signature' => ['nullable', 'array'],
|
||||||
|
'signature.*' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'auto_mailer' => ['nullable', 'boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\Activity;
|
||||||
use App\Models\Event as DecisionEventModel;
|
use App\Models\Event as DecisionEventModel;
|
||||||
|
use App\Services\DecisionEvents\ConditionEvaluator;
|
||||||
use App\Services\DecisionEvents\DecisionEventContext;
|
use App\Services\DecisionEvents\DecisionEventContext;
|
||||||
use App\Services\DecisionEvents\Registry;
|
use App\Services\DecisionEvents\Registry;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -68,6 +69,23 @@ public function handle(): void
|
|||||||
user: $activity->user,
|
user: $activity->user,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// [2] Condition check — skip the event if any condition is not met
|
||||||
|
$conditions = $this->config['conditions'] ?? [];
|
||||||
|
if (! empty($conditions)) {
|
||||||
|
$conditionsMet = app(ConditionEvaluator::class)->evaluate($conditions, $context);
|
||||||
|
if (! $conditionsMet) {
|
||||||
|
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||||
|
'status' => 'skipped',
|
||||||
|
'message' => 'Condition not met',
|
||||||
|
'finished_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [3] Resolve handler → handle()
|
||||||
$handler->handle($context, $this->config);
|
$handler->handle($context, $this->config);
|
||||||
|
|
||||||
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Email;
|
||||||
use App\Models\EmailLog;
|
use App\Models\EmailLog;
|
||||||
use App\Models\EmailLogStatus;
|
use App\Models\EmailLogStatus;
|
||||||
use App\Services\EmailSender;
|
use App\Services\EmailSender;
|
||||||
@@ -53,6 +54,10 @@ public function handle(): void
|
|||||||
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||||
$log->save();
|
$log->save();
|
||||||
|
|
||||||
|
if ($log->to_email) {
|
||||||
|
Email::query()->where('value', $log->to_email)->update(['failed' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
|
|
||||||
class Account extends Model
|
class Account extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'reference',
|
'reference',
|
||||||
@@ -58,6 +59,11 @@ public function payments(): HasMany
|
|||||||
return $this->hasMany(\App\Models\Payment::class);
|
return $this->hasMany(\App\Models\Payment::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function installments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(\App\Models\Installment::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function bookings(): HasMany
|
public function bookings(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\Booking::class);
|
return $this->hasMany(\App\Models\Booking::class);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Activity extends Model
|
class Activity extends Model
|
||||||
@@ -18,6 +19,7 @@ class Activity extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'due_date',
|
'due_date',
|
||||||
|
'call_back_at',
|
||||||
'amount',
|
'amount',
|
||||||
'note',
|
'note',
|
||||||
'action_id',
|
'action_id',
|
||||||
@@ -27,6 +29,13 @@ class Activity extends Model
|
|||||||
'client_case_id',
|
'client_case_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/*protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'call_back_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}*/
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'action_id',
|
'action_id',
|
||||||
'decision_id',
|
'decision_id',
|
||||||
@@ -146,4 +155,14 @@ public function user(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\User::class);
|
return $this->belongsTo(\App\Models\User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(\App\Models\CallLater::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function emailLogs(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(EmailLog::class, 'activity_email_logs');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_primary',
|
||||||
'is_active',
|
'is_active',
|
||||||
'valid',
|
'valid',
|
||||||
|
'failed',
|
||||||
'receive_auto_mails',
|
'receive_auto_mails',
|
||||||
'verified_at',
|
'verified_at',
|
||||||
'preferences',
|
'preferences',
|
||||||
@@ -28,6 +29,7 @@ class Email extends Model
|
|||||||
'is_primary' => 'boolean',
|
'is_primary' => 'boolean',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'valid' => 'boolean',
|
'valid' => 'boolean',
|
||||||
|
'failed' => 'boolean',
|
||||||
'receive_auto_mails' => 'boolean',
|
'receive_auto_mails' => 'boolean',
|
||||||
'verified_at' => 'datetime',
|
'verified_at' => 'datetime',
|
||||||
'preferences' => 'array',
|
'preferences' => 'array',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
enum EmailLogStatus: string
|
enum EmailLogStatus: string
|
||||||
@@ -83,4 +84,9 @@ public function body(): HasOne
|
|||||||
{
|
{
|
||||||
return $this->hasOne(EmailLogBody::class, 'email_log_id');
|
return $this->hasOne(EmailLogBody::class, 'email_log_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function activities(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Activity::class, 'activity_email_logs');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
|
||||||
class EmailTemplate extends Model
|
class EmailTemplate extends Model
|
||||||
@@ -19,10 +20,14 @@ class EmailTemplate extends Model
|
|||||||
'entity_types',
|
'entity_types',
|
||||||
'allow_attachments',
|
'allow_attachments',
|
||||||
'active',
|
'active',
|
||||||
|
'action_id',
|
||||||
|
'decision_id',
|
||||||
|
'client',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'active' => 'boolean',
|
'active' => 'boolean',
|
||||||
|
'client' => 'boolean',
|
||||||
'entity_types' => 'array',
|
'entity_types' => 'array',
|
||||||
'allow_attachments' => 'boolean',
|
'allow_attachments' => 'boolean',
|
||||||
];
|
];
|
||||||
@@ -31,4 +36,14 @@ public function documents(): MorphMany
|
|||||||
{
|
{
|
||||||
return $this->morphMany(Document::class, 'documentable');
|
return $this->morphMany(Document::class, 'documentable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function action(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Action::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decision(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Decision::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name', 'active', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
|
'name', 'active', 'auto_mailer', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
|
||||||
'reply_to_address', 'reply_to_name', 'priority', 'max_daily_quota', 'emails_sent_today',
|
'reply_to_address', 'reply_to_name', 'priority', 'signature', 'max_daily_quota', 'emails_sent_today',
|
||||||
'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at',
|
'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'active' => 'boolean',
|
'active' => 'boolean',
|
||||||
|
'auto_mailer' => 'boolean',
|
||||||
|
'signature' => 'array',
|
||||||
'last_success_at' => 'datetime',
|
'last_success_at' => 'datetime',
|
||||||
'last_error_at' => 'datetime',
|
'last_error_at' => 'datetime',
|
||||||
'test_checked_at' => 'datetime',
|
'test_checked_at' => 'datetime',
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ public function items()
|
|||||||
|
|
||||||
public const TYPE_SMS = 'sms';
|
public const TYPE_SMS = 'sms';
|
||||||
|
|
||||||
|
public const TYPE_EMAIL = 'email';
|
||||||
|
|
||||||
public const STATUS_DRAFT = 'draft';
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
public const STATUS_QUEUED = 'queued';
|
public const STATUS_QUEUED = 'queued';
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class User extends Authenticatable
|
|||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
'active',
|
'active',
|
||||||
|
'login_redirect',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
use App\Actions\Fortify\ResetUserPassword;
|
use App\Actions\Fortify\ResetUserPassword;
|
||||||
use App\Actions\Fortify\UpdateUserPassword;
|
use App\Actions\Fortify\UpdateUserPassword;
|
||||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||||
|
use App\Http\Responses\LoginResponse;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
class FortifyServiceProvider extends ServiceProvider
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
@@ -23,7 +25,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -59,10 +59,23 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
|||||||
// Resolve eligible recipients: client's person emails with receive_auto_mails = true
|
// Resolve eligible recipients: client's person emails with receive_auto_mails = true
|
||||||
$recipients = [];
|
$recipients = [];
|
||||||
if ($client && $client->person) {
|
if ($client && $client->person) {
|
||||||
$recipients = Email::query()
|
$emails = Email::query()
|
||||||
->where('person_id', $client->person->id)
|
->where('person_id', $client->person->id)
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
->where('receive_auto_mails', true)
|
->where('receive_auto_mails', true)
|
||||||
|
->get(['value', 'preferences']);
|
||||||
|
|
||||||
|
$recipients = $emails
|
||||||
|
->filter(function (Email $email) use ($decision): bool {
|
||||||
|
$decisionIds = $email->preferences['decision_ids'] ?? [];
|
||||||
|
|
||||||
|
// Empty list means "all decisions" — always receive
|
||||||
|
if (empty($decisionIds)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array((int) $decision->id, array_map('intval', $decisionIds), true);
|
||||||
|
})
|
||||||
->pluck('value')
|
->pluck('value')
|
||||||
->map(fn ($v) => strtolower(trim((string) $v)))
|
->map(fn ($v) => strtolower(trim((string) $v)))
|
||||||
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
|
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
|
||||||
@@ -77,7 +90,30 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
|||||||
// Ensure related names are available without extra queries
|
// Ensure related names are available without extra queries
|
||||||
$activity->loadMissing(['action', 'decision']);
|
$activity->loadMissing(['action', 'decision']);
|
||||||
|
|
||||||
|
// Ensure account is available on contract (needed for contract.account.* tokens)
|
||||||
|
if ($contract && ! $contract->relationLoaded('account')) {
|
||||||
|
$contract->load('account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the sending profile once — used both for signature tokens and as the actual sender.
|
||||||
|
// Prefer the profile explicitly requested via options, fall back to highest-priority active one.
|
||||||
|
$mailProfile = isset($options['mail_profile_id'])
|
||||||
|
? MailProfile::query()->find($options['mail_profile_id'])
|
||||||
|
: null;
|
||||||
|
$mailProfile ??= MailProfile::query()
|
||||||
|
->where('active', true)
|
||||||
|
->where('auto_mailer', true)
|
||||||
|
->orderBy('priority')
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
$mailProfile ??= MailProfile::query()
|
||||||
|
->where('active', true)
|
||||||
|
->orderBy('priority')
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
// Render content
|
// Render content
|
||||||
|
$bodyText = isset($options['body_text']) ? (string) $options['body_text'] : '';
|
||||||
$rendered = $this->renderer->render([
|
$rendered = $this->renderer->render([
|
||||||
'subject' => (string) $template->subject_template,
|
'subject' => (string) $template->subject_template,
|
||||||
'html' => (string) $template->html_template,
|
'html' => (string) $template->html_template,
|
||||||
@@ -89,6 +125,8 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
|||||||
'person' => $person,
|
'person' => $person,
|
||||||
'activity' => $activity,
|
'activity' => $activity,
|
||||||
'extra' => [],
|
'extra' => [],
|
||||||
|
'mail_profile' => $mailProfile,
|
||||||
|
'body_text' => $bodyText,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create the log and body
|
// Create the log and body
|
||||||
@@ -96,7 +134,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
|||||||
$log->fill([
|
$log->fill([
|
||||||
'uuid' => (string) \Str::uuid(),
|
'uuid' => (string) \Str::uuid(),
|
||||||
'template_id' => $template->id,
|
'template_id' => $template->id,
|
||||||
'mail_profile_id' => optional(MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first())->id,
|
'mail_profile_id' => $mailProfile?->id,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'to_email' => (string) ($recipients[0] ?? ''),
|
'to_email' => (string) ($recipients[0] ?? ''),
|
||||||
'to_recipients' => $recipients,
|
'to_recipients' => $recipients,
|
||||||
@@ -136,7 +174,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
|||||||
|
|
||||||
$log->body()->create([
|
$log->body()->create([
|
||||||
'body_html' => (string) ($rendered['html'] ?? ''),
|
'body_html' => (string) ($rendered['html'] ?? ''),
|
||||||
'body_text' => (string) ($rendered['text'] ?? ''),
|
'body_text' => $bodyText !== '' ? $bodyText : (string) ($rendered['text'] ?? ''),
|
||||||
'inline_css' => true,
|
'inline_css' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
class ClientCaseDataService
|
class ClientCaseDataService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Get paginated contracts for a client case with optional segment filtering.
|
* Get contracts for a client case with optional segment filtering.
|
||||||
*/
|
*/
|
||||||
public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int $perPage = 50): LengthAwarePaginator
|
public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Collection
|
||||||
{
|
{
|
||||||
$query = $clientCase->contracts()
|
$query = $clientCase->contracts()
|
||||||
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
|
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
|
||||||
@@ -40,9 +40,7 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int
|
|||||||
$query->forSegment($segmentId);
|
$query->forSegment($segmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$perPage = max(1, min(100, $perPage));
|
return $query->get();
|
||||||
|
|
||||||
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,7 +54,7 @@ public function getActivities(
|
|||||||
int $perPage = 20
|
int $perPage = 20
|
||||||
): LengthAwarePaginator {
|
): LengthAwarePaginator {
|
||||||
$query = $clientCase->activities()
|
$query = $clientCase->activities()
|
||||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name', 'emailLogs:id'])
|
||||||
->orderByDesc('created_at');
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
if (! empty($segmentId)) {
|
if (! empty($segmentId)) {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Contact;
|
||||||
|
|
||||||
|
use App\Models\Email;
|
||||||
|
use App\Models\Person\Person;
|
||||||
|
|
||||||
|
class EmailSelector
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Select the best email for a person following priority rules.
|
||||||
|
* Priority:
|
||||||
|
* 1) verified primary email that is active
|
||||||
|
* 2) primary email that is active
|
||||||
|
* 3) any active and valid email
|
||||||
|
* 4) first active email
|
||||||
|
*
|
||||||
|
* Returns an array shape: ['email' => ?Email, 'reason' => ?string]
|
||||||
|
*/
|
||||||
|
public function selectForPerson(Person $person): array
|
||||||
|
{
|
||||||
|
$emails = Email::query()
|
||||||
|
->where('person_id', $person->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('is_primary', 'desc')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($emails->isEmpty()) {
|
||||||
|
return ['email' => null, 'reason' => 'no_active_emails'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) verified primary
|
||||||
|
$email = $emails->first(fn (Email $e) => $e->is_primary && $e->verified_at !== null);
|
||||||
|
if ($email) {
|
||||||
|
return ['email' => $email, 'reason' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) primary (any verification)
|
||||||
|
$email = $emails->first(fn (Email $e) => $e->is_primary);
|
||||||
|
if ($email) {
|
||||||
|
return ['email' => $email, 'reason' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) valid (any)
|
||||||
|
$email = $emails->first(fn (Email $e) => $e->valid);
|
||||||
|
if ($email) {
|
||||||
|
return ['email' => $email, 'reason' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) first active
|
||||||
|
return ['email' => $emails->first(), 'reason' => null];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\DecisionEvents;
|
||||||
|
|
||||||
|
class ConditionEvaluator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Returns true when ALL conditions pass (AND logic).
|
||||||
|
*
|
||||||
|
* Each condition: { field: string, operator: string, value: mixed }
|
||||||
|
*
|
||||||
|
* @param array<int, array{field: string, operator: string, value: mixed}> $conditions
|
||||||
|
*/
|
||||||
|
public function evaluate(array $conditions, DecisionEventContext $context): bool
|
||||||
|
{
|
||||||
|
foreach ($conditions as $condition) {
|
||||||
|
if (! $this->evaluateOne($condition, $context)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function evaluateOne(array $condition, DecisionEventContext $context): bool
|
||||||
|
{
|
||||||
|
$field = $condition['field'] ?? '';
|
||||||
|
$operator = $condition['operator'] ?? '=';
|
||||||
|
$expected = $condition['value'] ?? null;
|
||||||
|
|
||||||
|
$actual = $this->resolveField($field, $context);
|
||||||
|
|
||||||
|
return $this->compare($actual, $operator, $expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveField(string $field, DecisionEventContext $context): mixed
|
||||||
|
{
|
||||||
|
return match ($field) {
|
||||||
|
'activity.amount' => $context->activity?->amount,
|
||||||
|
'activity.note' => $context->activity?->note,
|
||||||
|
'contract.active' => $context->contract !== null ? (bool) $context->contract->active : null,
|
||||||
|
'contract.account.balance_amount' => $this->resolveAccountBalance($context),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAccountBalance(DecisionEventContext $context): mixed
|
||||||
|
{
|
||||||
|
if (! $context->contract) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context->contract->loadMissing('account');
|
||||||
|
|
||||||
|
return $context->contract->account?->balance_amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function compare(mixed $actual, string $operator, mixed $expected): bool
|
||||||
|
{
|
||||||
|
if ($actual === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($operator, ['>', '>=', '<', '<='], true)) {
|
||||||
|
$actual = (float) $actual;
|
||||||
|
$expected = (float) $expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($operator) {
|
||||||
|
'=' => $actual == $expected,
|
||||||
|
'!=' => $actual != $expected,
|
||||||
|
'>' => $actual > $expected,
|
||||||
|
'>=' => $actual >= $expected,
|
||||||
|
'<' => $actual < $expected,
|
||||||
|
'<=' => $actual <= $expected,
|
||||||
|
'contains' => str_contains((string) $actual, (string) $expected),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns available condition field definitions for the frontend.
|
||||||
|
*
|
||||||
|
* @return array<int, array{key: string, label: string, type: string}>
|
||||||
|
*/
|
||||||
|
public static function availableFields(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['key' => 'activity.amount', 'label' => 'Aktivnost – znesek', 'type' => 'numeric'],
|
||||||
|
['key' => 'activity.note', 'label' => 'Aktivnost – opomba', 'type' => 'string'],
|
||||||
|
['key' => 'contract.active', 'label' => 'Pogodba – aktivna', 'type' => 'boolean'],
|
||||||
|
['key' => 'contract.account.balance_amount', 'label' => 'Račun – stanje', 'type' => 'numeric'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns available operators grouped by field type.
|
||||||
|
*
|
||||||
|
* @return array<string, array<int, array{key: string, label: string}>>
|
||||||
|
*/
|
||||||
|
public static function availableOperators(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'numeric' => [
|
||||||
|
['key' => '=', 'label' => 'je enako'],
|
||||||
|
['key' => '!=', 'label' => 'ni enako'],
|
||||||
|
['key' => '>', 'label' => 'je večje od'],
|
||||||
|
['key' => '>=', 'label' => 'je večje ali enako'],
|
||||||
|
['key' => '<', 'label' => 'je manjše od'],
|
||||||
|
['key' => '<=', 'label' => 'je manjše ali enako'],
|
||||||
|
],
|
||||||
|
'string' => [
|
||||||
|
['key' => '=', 'label' => 'je enako'],
|
||||||
|
['key' => '!=', 'label' => 'ni enako'],
|
||||||
|
['key' => 'contains', 'label' => 'vsebuje'],
|
||||||
|
],
|
||||||
|
'boolean' => [
|
||||||
|
['key' => '=', 'label' => 'je'],
|
||||||
|
['key' => '!=', 'label' => 'ni'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@ public function handle(DecisionEventContext $context, array $config = []): void
|
|||||||
$setting->reactivate = (bool) $config['reactivate'];
|
$setting->reactivate = (bool) $config['reactivate'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel all active FieldJobs for this contract before archiving (raw update to avoid boot-event side effects)
|
||||||
|
\DB::table('field_jobs')
|
||||||
|
->where('contract_id', $contractId)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->update(['cancelled_at' => now(), 'updated_at' => now()]);
|
||||||
|
|
||||||
$results = app(ArchiveExecutor::class)->executeSetting(
|
$results = app(ArchiveExecutor::class)->executeSetting(
|
||||||
$setting,
|
$setting,
|
||||||
['contract_id' => $contractId],
|
['contract_id' => $contractId],
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\DecisionEvents\Handlers;
|
||||||
|
|
||||||
|
use App\Models\CallLater;
|
||||||
|
use App\Services\DecisionEvents\Contracts\DecisionEventHandler;
|
||||||
|
use App\Services\DecisionEvents\DecisionEventContext;
|
||||||
|
|
||||||
|
class CallLaterHandler implements DecisionEventHandler
|
||||||
|
{
|
||||||
|
public function handle(DecisionEventContext $context, array $config = []): void
|
||||||
|
{
|
||||||
|
$activity = $context->activity;
|
||||||
|
|
||||||
|
if (empty($activity->call_back_at)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CallLater::create([
|
||||||
|
'activity_id' => $activity->id,
|
||||||
|
'client_case_id' => $activity->client_case_id,
|
||||||
|
'contract_id' => $activity->contract_id,
|
||||||
|
'user_id' => $activity->user_id,
|
||||||
|
'call_back_at' => $activity->call_back_at,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,15 +17,19 @@ class Registry
|
|||||||
'add_segment' => AddSegmentHandler::class,
|
'add_segment' => AddSegmentHandler::class,
|
||||||
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class,
|
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class,
|
||||||
'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class,
|
'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class,
|
||||||
|
'add_call_later' => \App\Services\DecisionEvents\Handlers\CallLaterHandler::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function resolve(string $key): DecisionEventHandler
|
public static function resolve(string $key): DecisionEventHandler
|
||||||
{
|
{
|
||||||
$key = trim(strtolower($key));
|
$key = trim(strtolower($key));
|
||||||
$class = static::$map[$key] ?? null;
|
$class = static::$map[$key] ?? null;
|
||||||
if (! $class || ! class_exists($class)) {
|
if (! $class) {
|
||||||
throw new InvalidArgumentException("Unknown decision event handler for key: {$key}");
|
throw new InvalidArgumentException("Unknown decision event handler for key: {$key}");
|
||||||
}
|
}
|
||||||
|
if (! class_exists($class)) {
|
||||||
|
throw new InvalidArgumentException("Handler class {$class} for key {$key} does not exist (check autoload)");
|
||||||
|
}
|
||||||
$handler = app($class);
|
$handler = app($class);
|
||||||
if (! $handler instanceof DecisionEventHandler) {
|
if (! $handler instanceof DecisionEventHandler) {
|
||||||
throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler");
|
throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler");
|
||||||
|
|||||||
@@ -152,19 +152,6 @@ public function sendFromLog(EmailLog $log): array
|
|||||||
$email->to(new Address($singleTo, (string) ($log->to_name ?? '')));
|
$email->to(new Address($singleTo, (string) ($log->to_name ?? '')));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always BCC the sender mailbox if present and not already in To
|
|
||||||
$senderBcc = null;
|
|
||||||
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
|
||||||
// Check duplicates against toList
|
|
||||||
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
|
||||||
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
|
||||||
$senderBcc = $fromAddr;
|
|
||||||
$email->bcc(new Address($senderBcc));
|
|
||||||
// Persist BCC for auditing
|
|
||||||
$log->bcc = [$senderBcc];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! empty($text)) {
|
if (! empty($text)) {
|
||||||
$email->text($text);
|
$email->text($text);
|
||||||
}
|
}
|
||||||
@@ -304,10 +291,6 @@ public function sendFromLog(EmailLog $log): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$mailer->send($email);
|
$mailer->send($email);
|
||||||
// Save log if we modified BCC
|
|
||||||
if (! empty($log->getAttribute('bcc'))) {
|
|
||||||
$log->save();
|
|
||||||
}
|
|
||||||
$headers = $email->getHeaders();
|
$headers = $email->getHeaders();
|
||||||
$messageIdHeader = $headers->get('Message-ID');
|
$messageIdHeader = $headers->get('Message-ID');
|
||||||
$messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null;
|
$messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null;
|
||||||
@@ -330,15 +313,6 @@ public function sendFromLog(EmailLog $log): array
|
|||||||
$message->to($singleTo);
|
$message->to($singleTo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// BCC the sender mailbox if resolvable and not already in To
|
|
||||||
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
|
|
||||||
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
|
||||||
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
|
||||||
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
|
||||||
$message->bcc($fromAddr);
|
|
||||||
$log->bcc = [$fromAddr];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$message->subject($subject);
|
$message->subject($subject);
|
||||||
if (! empty($log->reply_to)) {
|
if (! empty($log->reply_to)) {
|
||||||
$message->replyTo($log->reply_to);
|
$message->replyTo($log->reply_to);
|
||||||
@@ -464,15 +438,6 @@ public function sendFromLog(EmailLog $log): array
|
|||||||
$message->to($singleTo);
|
$message->to($singleTo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// BCC the sender mailbox if resolvable and not already in To
|
|
||||||
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
|
|
||||||
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
|
|
||||||
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
|
|
||||||
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
|
|
||||||
$message->bcc($fromAddr);
|
|
||||||
$log->bcc = [$fromAddr];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$message->subject($subject);
|
$message->subject($subject);
|
||||||
if (! empty($log->reply_to)) {
|
if (! empty($log->reply_to)) {
|
||||||
$message->replyTo($log->reply_to);
|
$message->replyTo($log->reply_to);
|
||||||
|
|||||||
@@ -30,17 +30,49 @@ public function render(array $template, array $ctx): array
|
|||||||
return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) {
|
return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) {
|
||||||
$key = $m[1];
|
$key = $m[1];
|
||||||
|
|
||||||
return (string) data_get($map, $key, '');
|
// body_text is handled separately by applyBodyText(); preserve as literal
|
||||||
|
if ($key === 'body_text') {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = data_get($map, $key, '');
|
||||||
|
|
||||||
|
// If the resolved value is an array (e.g. {{ contract.meta }} used directly),
|
||||||
|
// return empty string instead of triggering "Array to string conversion".
|
||||||
|
if (is_array($value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $value;
|
||||||
}, $input);
|
}, $input);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$bodyText = isset($ctx['body_text']) ? (string) $ctx['body_text'] : '';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'subject' => $replacer($template['subject']) ?? '',
|
'subject' => $replacer($template['subject']) ?? '',
|
||||||
'html' => $replacer($template['html'] ?? null) ?? null,
|
'html' => $this->applyBodyText($replacer($template['html'] ?? null) ?? null, $bodyText, html: true),
|
||||||
'text' => $replacer($template['text'] ?? null) ?? null,
|
'text' => $this->applyBodyText($replacer($template['text'] ?? null) ?? null, $bodyText, html: false),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitute the literal {{body_text}} placeholder with the user-supplied body text.
|
||||||
|
* In HTML context the text is HTML-escaped and newlines are converted to <br>.
|
||||||
|
* In plain-text context the raw value is used.
|
||||||
|
*/
|
||||||
|
public function applyBodyText(?string $content, string $bodyText, bool $html = true): ?string
|
||||||
|
{
|
||||||
|
if ($content === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$replacement = $html
|
||||||
|
? nl2br(htmlspecialchars($bodyText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'))
|
||||||
|
: $bodyText;
|
||||||
|
|
||||||
|
return preg_replace('/{{\s*body_text\s*}}/', $replacement, $content);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
|
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
|
||||||
*/
|
*/
|
||||||
@@ -145,12 +177,18 @@ protected function buildMap(array $ctx): array
|
|||||||
'id' => data_get($co, 'id'),
|
'id' => data_get($co, 'id'),
|
||||||
'uuid' => data_get($co, 'uuid'),
|
'uuid' => data_get($co, 'uuid'),
|
||||||
'reference' => data_get($co, 'reference'),
|
'reference' => data_get($co, 'reference'),
|
||||||
// Format amounts in EU style for emails
|
// Account amounts — sourced from the related Account model
|
||||||
'amount' => $formatMoneyEu(data_get($co, 'amount')),
|
'account' => [
|
||||||
|
'balance_amount' => $formatMoneyEu(data_get($co, 'account.balance_amount')),
|
||||||
|
'initial_amount' => $formatMoneyEu(data_get($co, 'account.initial_amount')),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
$meta = data_get($co, 'meta');
|
$meta = data_get($co, 'meta');
|
||||||
|
if (is_string($meta)) {
|
||||||
|
$meta = json_decode($meta, true) ?? [];
|
||||||
|
}
|
||||||
if (is_array($meta)) {
|
if (is_array($meta)) {
|
||||||
$out['contract']['meta'] = $meta;
|
$out['contract']['meta'] = $this->flattenMetaForTemplate($meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isset($ctx['activity'])) {
|
if (isset($ctx['activity'])) {
|
||||||
@@ -172,7 +210,50 @@ protected function buildMap(array $ctx): array
|
|||||||
if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
|
if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
|
||||||
$out['extra'] = $ctx['extra'];
|
$out['extra'] = $ctx['extra'];
|
||||||
}
|
}
|
||||||
|
if (isset($ctx['mail_profile'])) {
|
||||||
|
$mp = $ctx['mail_profile'];
|
||||||
|
$out['profile'] = [
|
||||||
|
'signature' => is_array($mp->signature) ? $mp->signature : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a contract meta array so every leaf value is accessible by its bare key.
|
||||||
|
*
|
||||||
|
* Handles three formats stored in the wild:
|
||||||
|
* 1. Numeric wrapper: { "1": { "sklic": "SI00…", "job_days": 1 } }
|
||||||
|
* → { "sklic": "SI00…", "job_days": 1 }
|
||||||
|
* 2. Structured entry: { "sklic": { "value": "SI00…", "type": "string" } }
|
||||||
|
* → { "sklic": "SI00…" }
|
||||||
|
* 3. Already flat: { "sklic": "SI00…" }
|
||||||
|
* → { "sklic": "SI00…" }
|
||||||
|
*/
|
||||||
|
private function flattenMetaForTemplate(array $meta): array
|
||||||
|
{
|
||||||
|
$flat = [];
|
||||||
|
foreach ($meta as $key => $item) {
|
||||||
|
if (!is_array($item)) {
|
||||||
|
// Plain scalar — keep as-is (format 3)
|
||||||
|
if (!array_key_exists($key, $flat)) {
|
||||||
|
$flat[$key] = $item;
|
||||||
|
}
|
||||||
|
} elseif (array_key_exists('value', $item)) {
|
||||||
|
// Structured { value, type, title } entry (format 2)
|
||||||
|
$flat[$key] = $item['value'];
|
||||||
|
} elseif (is_numeric($key)) {
|
||||||
|
// Numeric wrapper key — recurse and alias without the prefix (format 1)
|
||||||
|
foreach ($this->flattenMetaForTemplate($item) as $nk => $nv) {
|
||||||
|
if (!array_key_exists($nk, $flat)) {
|
||||||
|
$flat[$nk] = $nv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-numeric nested arrays without a 'value' key are silently skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
return $flat;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -10,21 +10,21 @@
|
|||||||
"barryvdh/laravel-dompdf": "^3.1",
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
"diglactic/laravel-breadcrumbs": "^10.0",
|
"diglactic/laravel-breadcrumbs": "^10.0",
|
||||||
"http-interop/http-factory-guzzle": "^1.2",
|
"http-interop/http-factory-guzzle": "^1.2",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^3.0",
|
||||||
"laravel/framework": "12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/jetstream": "^5.2",
|
"laravel/jetstream": "^5.2",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/scout": "^10.11",
|
"laravel/scout": "^10.11",
|
||||||
"laravel/tinker": "^2.9",
|
"laravel/tinker": "^2.9",
|
||||||
"maatwebsite/excel": "^3.1",
|
"maatwebsite/excel": "^3.1",
|
||||||
"meilisearch/meilisearch-php": "^1.11",
|
"meilisearch/meilisearch-php": "^1.11",
|
||||||
"robertboes/inertia-breadcrumbs": "dev-laravel-12",
|
"robertboes/inertia-breadcrumbs": "^1.0",
|
||||||
"tightenco/ziggy": "^2.0",
|
"tightenco/ziggy": "^2.0",
|
||||||
"tijsverkoyen/css-to-inline-styles": "^2.2"
|
"tijsverkoyen/css-to-inline-styles": "^2.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"laravel/boost": "^1.1",
|
"laravel/boost": "^2.2",
|
||||||
"laravel/pint": "^1.13",
|
"laravel/pint": "^1.13",
|
||||||
"laravel/sail": "^1.26",
|
"laravel/sail": "^1.26",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
|
|||||||
Generated
+1031
-746
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('activities', function (Blueprint $table) {
|
||||||
|
$table->dateTime('call_back_at')->nullable()->after('due_date');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('activities', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('call_back_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('call_laters', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete();
|
||||||
|
$table->foreignId('client_case_id')->constrained('client_cases')->cascadeOnDelete();
|
||||||
|
$table->foreignId('contract_id')->nullable()->constrained('contracts')->nullOnDelete();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->dateTime('call_back_at');
|
||||||
|
$table->dateTime('completed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('call_laters');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('installments', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('account_id')->constrained('accounts')->cascadeOnDelete();
|
||||||
|
$table->decimal('amount', 20, 4);
|
||||||
|
$table->decimal('balance_before', 20, 4)->nullable();
|
||||||
|
$table->string('currency', 3)->default('EUR');
|
||||||
|
$table->string('reference', 100)->nullable();
|
||||||
|
$table->timestamp('installment_at')->nullable();
|
||||||
|
$table->json('meta')->nullable();
|
||||||
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->foreignId('activity_id')->nullable()->constrained('activities')->nullOnDelete();
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('installments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('installment_settings', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('default_currency', 3)->default('EUR');
|
||||||
|
$table->boolean('create_activity_on_installment')->default(false);
|
||||||
|
$table->foreignId('default_decision_id')->nullable()->constrained('decisions')->nullOnDelete();
|
||||||
|
$table->foreignId('default_action_id')->nullable()->constrained('actions')->nullOnDelete();
|
||||||
|
$table->string('activity_note_template', 255)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('installment_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('contract_settings', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->boolean('create_activity_on_balance_change')->default(false);
|
||||||
|
$table->foreignId('default_action_id')->nullable()->constrained('actions')->nullOnDelete();
|
||||||
|
$table->foreignId('default_decision_id')->nullable()->constrained('decisions')->nullOnDelete();
|
||||||
|
$table->string('activity_note_template', 255)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('contract_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::statement('ALTER TABLE field_jobs ALTER COLUMN assigned_at TYPE timestamp USING assigned_at::timestamp');
|
||||||
|
DB::statement('ALTER TABLE field_jobs ALTER COLUMN completed_at TYPE timestamp USING completed_at::timestamp');
|
||||||
|
DB::statement('ALTER TABLE field_jobs ALTER COLUMN cancelled_at TYPE timestamp USING cancelled_at::timestamp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::statement('ALTER TABLE field_jobs ALTER COLUMN assigned_at TYPE date USING assigned_at::date');
|
||||||
|
DB::statement('ALTER TABLE field_jobs ALTER COLUMN completed_at TYPE date USING completed_at::date');
|
||||||
|
DB::statement('ALTER TABLE field_jobs ALTER COLUMN cancelled_at TYPE date USING cancelled_at::date');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('login_redirect')->nullable()->after('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('login_redirect');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('mail_profiles', function (Blueprint $table) {
|
||||||
|
$table->jsonb('signature')->nullable()->after('priority');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('mail_profiles', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('signature');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('email_templates', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('action_id')->nullable()->after('active')->constrained('actions')->nullOnDelete();
|
||||||
|
$table->foreignId('decision_id')->nullable()->after('action_id')->constrained('decisions')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('email_templates', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign(['action_id']);
|
||||||
|
$table->dropForeign(['decision_id']);
|
||||||
|
$table->dropColumn(['action_id', 'decision_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('mail_profiles', function (Blueprint $table): void {
|
||||||
|
$table->boolean('auto_mailer')->default(false)->after('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('mail_profiles', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('auto_mailer');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('activity_email_logs', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete();
|
||||||
|
$table->foreignId('email_log_id')->constrained('email_logs')->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['activity_id', 'email_log_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('activity_email_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('emails', function (Blueprint $table) {
|
||||||
|
$table->boolean('failed')->default(false)->after('valid');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('emails', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('email_templates', function (Blueprint $table): void {
|
||||||
|
$table->boolean('client')->default(false)->after('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('email_templates', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('client');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -31,6 +31,11 @@ public function run(): void
|
|||||||
'name' => 'End field job',
|
'name' => 'End field job',
|
||||||
'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).',
|
'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'key' => 'add_call_later',
|
||||||
|
'name' => 'Klic kasneje',
|
||||||
|
'description' => 'Ustvari zapis za povratni klic ob določenem datumu in uri.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public function run(): void
|
|||||||
$this->seedSegmentActivityCountsReport();
|
$this->seedSegmentActivityCountsReport();
|
||||||
$this->seedActionsDecisionsCountReport();
|
$this->seedActionsDecisionsCountReport();
|
||||||
$this->seedActivitiesPerPeriodReport();
|
$this->seedActivitiesPerPeriodReport();
|
||||||
|
$this->seedActivitiesDetailReport();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function seedActiveContractsReport(): void
|
protected function seedActiveContractsReport(): void
|
||||||
@@ -783,4 +784,265 @@ protected function seedActivitiesPerPeriodReport(): void
|
|||||||
'order' => 0,
|
'order' => 0,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function seedActivitiesDetailReport(): void
|
||||||
|
{
|
||||||
|
$report = Report::create([
|
||||||
|
'slug' => 'activities-detail',
|
||||||
|
'name' => 'Aktivnosti – pregled',
|
||||||
|
'description' => 'Podroben pregled aktivnosti z možnostjo filtriranja po stranki, datumu, akciji in odločitvi.',
|
||||||
|
'category' => 'activities',
|
||||||
|
'enabled' => true,
|
||||||
|
'order' => 7,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Entities (joins)
|
||||||
|
$report->entities()->create([
|
||||||
|
'model_class' => 'App\\Models\\Activity',
|
||||||
|
'join_type' => 'base',
|
||||||
|
'order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$report->entities()->create([
|
||||||
|
'model_class' => 'App\\Models\\Action',
|
||||||
|
'join_type' => 'leftJoin',
|
||||||
|
'join_first' => 'activities.action_id',
|
||||||
|
'join_operator' => '=',
|
||||||
|
'join_second' => 'actions.id',
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$report->entities()->create([
|
||||||
|
'model_class' => 'App\\Models\\Decision',
|
||||||
|
'join_type' => 'leftJoin',
|
||||||
|
'join_first' => 'activities.decision_id',
|
||||||
|
'join_operator' => '=',
|
||||||
|
'join_second' => 'decisions.id',
|
||||||
|
'order' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$report->entities()->create([
|
||||||
|
'model_class' => 'App\\Models\\Contract',
|
||||||
|
'join_type' => 'leftJoin',
|
||||||
|
'join_first' => 'activities.contract_id',
|
||||||
|
'join_operator' => '=',
|
||||||
|
'join_second' => 'contracts.id',
|
||||||
|
'order' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$report->entities()->create([
|
||||||
|
'model_class' => 'App\\Models\\ClientCase',
|
||||||
|
'join_type' => 'leftJoin',
|
||||||
|
'join_first' => 'activities.client_case_id',
|
||||||
|
'join_operator' => '=',
|
||||||
|
'join_second' => 'client_cases.id',
|
||||||
|
'order' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$report->entities()->create([
|
||||||
|
'model_class' => 'App\\Models\\Client',
|
||||||
|
'join_type' => 'leftJoin',
|
||||||
|
'join_first' => 'client_cases.client_id',
|
||||||
|
'join_operator' => '=',
|
||||||
|
'join_second' => 'clients.id',
|
||||||
|
'order' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$report->entities()->createMany([
|
||||||
|
[
|
||||||
|
'model_class' => 'App\\Models\\Person\\Person',
|
||||||
|
'alias' => 'client_people',
|
||||||
|
'join_type' => 'leftJoin',
|
||||||
|
'join_first' => 'clients.person_id',
|
||||||
|
'join_operator' => '=',
|
||||||
|
'join_second' => 'client_people.id',
|
||||||
|
'order' => 6,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'model_class' => 'App\\Models\\Person\\Person',
|
||||||
|
'alias' => 'subject_people',
|
||||||
|
'join_type' => 'leftJoin',
|
||||||
|
'join_first' => 'client_cases.person_id',
|
||||||
|
'join_operator' => '=',
|
||||||
|
'join_second' => 'subject_people.id',
|
||||||
|
'order' => 7,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
$report->columns()->createMany([
|
||||||
|
[
|
||||||
|
'key' => 'contract_reference',
|
||||||
|
'label' => 'Pogodba',
|
||||||
|
'type' => 'string',
|
||||||
|
'expression' => 'contracts.reference',
|
||||||
|
'sortable' => true,
|
||||||
|
'visible' => true,
|
||||||
|
'order' => 0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'naziv',
|
||||||
|
'label' => 'Naziv',
|
||||||
|
'type' => 'string',
|
||||||
|
'expression' => 'subject_people.full_name',
|
||||||
|
'sortable' => true,
|
||||||
|
'visible' => true,
|
||||||
|
'order' => 1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'stranka',
|
||||||
|
'label' => 'Stranka',
|
||||||
|
'type' => 'string',
|
||||||
|
'expression' => 'client_people.full_name',
|
||||||
|
'sortable' => true,
|
||||||
|
'visible' => true,
|
||||||
|
'order' => 2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'aktivnost',
|
||||||
|
'label' => 'Aktivnost',
|
||||||
|
'type' => 'string',
|
||||||
|
'expression' => "CONCAT(COALESCE(actions.name, ''), ' / ', COALESCE(decisions.name, ''))",
|
||||||
|
'sortable' => false,
|
||||||
|
'visible' => true,
|
||||||
|
'order' => 3,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'datum',
|
||||||
|
'label' => 'Datum',
|
||||||
|
'type' => 'date',
|
||||||
|
'expression' => 'DATE(activities.created_at)',
|
||||||
|
'sortable' => true,
|
||||||
|
'visible' => true,
|
||||||
|
'order' => 4,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'opomba',
|
||||||
|
'label' => 'Opomba',
|
||||||
|
'type' => 'string',
|
||||||
|
'expression' => 'activities.note',
|
||||||
|
'sortable' => false,
|
||||||
|
'visible' => true,
|
||||||
|
'order' => 5,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'zapadlost',
|
||||||
|
'label' => 'Zapadlost',
|
||||||
|
'type' => 'date',
|
||||||
|
'expression' => 'activities.due_date',
|
||||||
|
'sortable' => true,
|
||||||
|
'visible' => true,
|
||||||
|
'order' => 6,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'znesek',
|
||||||
|
'label' => 'Znesek',
|
||||||
|
'type' => 'currency',
|
||||||
|
'expression' => 'activities.amount',
|
||||||
|
'sortable' => true,
|
||||||
|
'visible' => true,
|
||||||
|
'order' => 7,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
$report->filters()->createMany([
|
||||||
|
[
|
||||||
|
'key' => 'client_uuid',
|
||||||
|
'label' => 'Stranka',
|
||||||
|
'type' => 'select:client',
|
||||||
|
'nullable' => true,
|
||||||
|
'order' => 0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'from',
|
||||||
|
'label' => 'Datum od',
|
||||||
|
'type' => 'date',
|
||||||
|
'nullable' => true,
|
||||||
|
'order' => 1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'to',
|
||||||
|
'label' => 'Datum do',
|
||||||
|
'type' => 'date',
|
||||||
|
'nullable' => true,
|
||||||
|
'order' => 2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'action_id',
|
||||||
|
'label' => 'Akcija',
|
||||||
|
'type' => 'select:action',
|
||||||
|
'nullable' => true,
|
||||||
|
'order' => 3,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'decision_id',
|
||||||
|
'label' => 'Odločitev',
|
||||||
|
'type' => 'select:decision',
|
||||||
|
'nullable' => true,
|
||||||
|
'order' => 4,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Conditions (all filter-based, skipped when null)
|
||||||
|
$report->conditions()->createMany([
|
||||||
|
[
|
||||||
|
'column' => 'activities.created_at',
|
||||||
|
'operator' => '>=',
|
||||||
|
'value_type' => 'filter',
|
||||||
|
'filter_key' => 'from',
|
||||||
|
'logical_operator' => 'AND',
|
||||||
|
'group_id' => 1,
|
||||||
|
'order' => 0,
|
||||||
|
'enabled' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'column' => 'activities.created_at',
|
||||||
|
'operator' => '<=',
|
||||||
|
'value_type' => 'filter',
|
||||||
|
'filter_key' => 'to',
|
||||||
|
'logical_operator' => 'AND',
|
||||||
|
'group_id' => 1,
|
||||||
|
'order' => 1,
|
||||||
|
'enabled' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'column' => 'clients.uuid',
|
||||||
|
'operator' => '=',
|
||||||
|
'value_type' => 'filter',
|
||||||
|
'filter_key' => 'client_uuid',
|
||||||
|
'logical_operator' => 'AND',
|
||||||
|
'group_id' => 2,
|
||||||
|
'order' => 0,
|
||||||
|
'enabled' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'column' => 'activities.action_id',
|
||||||
|
'operator' => '=',
|
||||||
|
'value_type' => 'filter',
|
||||||
|
'filter_key' => 'action_id',
|
||||||
|
'logical_operator' => 'AND',
|
||||||
|
'group_id' => 3,
|
||||||
|
'order' => 0,
|
||||||
|
'enabled' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'column' => 'activities.decision_id',
|
||||||
|
'operator' => '=',
|
||||||
|
'value_type' => 'filter',
|
||||||
|
'filter_key' => 'decision_id',
|
||||||
|
'logical_operator' => 'AND',
|
||||||
|
'group_id' => 4,
|
||||||
|
'order' => 0,
|
||||||
|
'enabled' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Order
|
||||||
|
$report->orders()->create([
|
||||||
|
'column' => 'activities.created_at',
|
||||||
|
'direction' => 'DESC',
|
||||||
|
'order' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+42
-138
@@ -46,7 +46,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inertiajs/vue3": "2.0",
|
"@inertiajs/vue3": "^3.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -952,26 +952,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@inertiajs/core": {
|
"node_modules/@inertiajs/core": {
|
||||||
"version": "2.0.17",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.0.17.tgz",
|
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.0.3.tgz",
|
||||||
"integrity": "sha512-tvYoqiouQSJrP7i7zVq61yyuEjlL96UU4nkkOWtOajXZlubGN4XrgRpnygpDk1KBO8V2yBab3oUZm+aZImwTHg==",
|
"integrity": "sha512-/4sW/cfNpvujjVOZlB5UNypLGNySs7X7V8IMLNSK8+3j1KsUYGS5wpLd9EqAu8wy8RiW7PPra2rPwB6Lx/ACow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.8.2",
|
"@jridgewell/trace-mapping": "^0.3.31",
|
||||||
"es-toolkit": "^1.34.1",
|
"es-toolkit": "^1.33.0",
|
||||||
"qs": "^6.9.0"
|
"laravel-precognition": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"axios": "^1.13.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"axios": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@inertiajs/vue3": {
|
"node_modules/@inertiajs/vue3": {
|
||||||
"version": "2.0.17",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.0.17.tgz",
|
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-3.0.3.tgz",
|
||||||
"integrity": "sha512-Al0IMHQSj5aTQBLUAkljFEMCw4YRwSiOSKzN8LAbvJpKwvJFgc/wSj3wVVpr/AO9y9mz1w2mtvjnDoOzsntPLw==",
|
"integrity": "sha512-bhJN+GS66g1tYH1p6flKkG1N8oaT5J7ZLqBkavN9mHC6bVfoQCUG6sCuA07WTDfo9tDaxU89wsSSAf4mhn3SuA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inertiajs/core": "2.0.17",
|
"@inertiajs/core": "3.0.3",
|
||||||
"es-toolkit": "^1.33.0"
|
"es-toolkit": "^1.33.0",
|
||||||
|
"laravel-precognition": "^2.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.0.0"
|
"vue": "^3.0.0"
|
||||||
@@ -3804,9 +3813,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-toolkit": {
|
"node_modules/es-toolkit": {
|
||||||
"version": "1.43.0",
|
"version": "1.45.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||||
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -4372,6 +4381,24 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/laravel-precognition": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-toolkit": "^1.32.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"axios": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"axios": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/laravel-vite-plugin": {
|
"node_modules/laravel-vite-plugin": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
|
||||||
@@ -4875,19 +4902,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/object-inspect": {
|
|
||||||
"version": "1.13.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-is": {
|
"node_modules/object-is": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||||
@@ -5098,22 +5112,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
|
||||||
"version": "6.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/quickselect": {
|
"node_modules/quickselect": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||||
@@ -5361,82 +5359,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"object-inspect": "^1.13.3",
|
|
||||||
"side-channel-list": "^1.0.0",
|
|
||||||
"side-channel-map": "^1.0.1",
|
|
||||||
"side-channel-weakmap": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-list": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"object-inspect": "^1.13.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-map": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.5",
|
|
||||||
"object-inspect": "^1.13.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel-weakmap": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.5",
|
|
||||||
"object-inspect": "^1.13.3",
|
|
||||||
"side-channel-map": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/skema": {
|
"node_modules/skema": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz",
|
||||||
@@ -6029,24 +5951,6 @@
|
|||||||
"which": "bin/which"
|
"which": "bin/which"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.8.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
+5
-3
@@ -7,7 +7,7 @@
|
|||||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inertiajs/vue3": "2.0",
|
"@inertiajs/vue3": "^3.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -34,12 +34,13 @@
|
|||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@heroicons/vue": "^2.2.0",
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@internationalized/date": "^3.10.0",
|
"@internationalized/date": "^3.10.0",
|
||||||
|
"@lucide/vue": "^1.21.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@unovis/ts": "^1.6.2",
|
"@unovis/ts": "^1.6.2",
|
||||||
"@unovis/vue": "^1.6.2",
|
"@unovis/vue": "^1.6.2",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vuepic/vue-datepicker": "^11.0.3",
|
"@vuepic/vue-datepicker": "^11.0.3",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"apexcharts": "^4.7.0",
|
"apexcharts": "^4.7.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clean": "^4.0.2",
|
"clean": "^4.0.2",
|
||||||
@@ -51,11 +52,12 @@
|
|||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"preline": "^2.7.0",
|
"preline": "^2.7.0",
|
||||||
"quill": "^1.3.7",
|
"quill": "^1.3.7",
|
||||||
"reka-ui": "^2.7.0",
|
"reka-ui": "^2.10.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tailwindcss-inner-border": "^0.2.0",
|
"tailwindcss-inner-border": "^0.2.0",
|
||||||
"v-calendar": "^3.1.2",
|
"v-calendar": "^3.1.2",
|
||||||
|
"vaul-vue": "^0.4.1",
|
||||||
"vee-validate": "^4.15.1",
|
"vee-validate": "^4.15.1",
|
||||||
"vue-currency-input": "^3.2.1",
|
"vue-currency-input": "^3.2.1",
|
||||||
"vue-multiselect": "^3.4.0",
|
"vue-multiselect": "^3.4.0",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
<testsuite name="Feature">
|
<testsuite name="Feature">
|
||||||
<directory>tests/Feature</directory>
|
<directory>tests/Feature</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
|
<testsuite name="Pure">
|
||||||
|
<directory>tests/Pure</directory>
|
||||||
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<source>
|
<source>
|
||||||
<include>
|
<include>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@tanstack/vue-table";
|
} from "@tanstack/vue-table";
|
||||||
import { valueUpdater } from "@/lib/utils";
|
import { valueUpdater } from "@/lib/utils";
|
||||||
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
|
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
|
||||||
import DataTablePagination from "./DataTablePagination.vue";
|
import DataTablePaginationClient from "./DataTablePaginationClient.vue";
|
||||||
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
||||||
import DataTableToolbar from "./DataTableToolbar.vue";
|
import DataTableToolbar from "./DataTableToolbar.vue";
|
||||||
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
|
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
|
||||||
@@ -618,7 +618,14 @@ defineExpose({
|
|||||||
|
|
||||||
<!-- Client-side pagination -->
|
<!-- Client-side pagination -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<DataTablePagination :table="table" />
|
<DataTablePaginationClient
|
||||||
|
:current-page="table.getState().pagination.pageIndex"
|
||||||
|
:last-page="table.getPageCount()"
|
||||||
|
:total="table.getFilteredRowModel().rows.length"
|
||||||
|
:showing-from="table.getFilteredSelectedRowModel().rows.length"
|
||||||
|
:showing-to="table.getFilteredRowModel().rows.length"
|
||||||
|
:table="table"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const props = defineProps({
|
|||||||
showGoto: { type: Boolean, default: true },
|
showGoto: { type: Boolean, default: true },
|
||||||
maxPageLinks: { type: Number, default: 5 },
|
maxPageLinks: { type: Number, default: 5 },
|
||||||
perPage: { type: Number, default: 10 },
|
perPage: { type: Number, default: 10 },
|
||||||
|
table: { type: Object, required: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:page"]);
|
const emit = defineEmits(["update:page"]);
|
||||||
@@ -34,7 +35,7 @@ function goToPageInput() {
|
|||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
if (!Number.isFinite(n)) return;
|
if (!Number.isFinite(n)) return;
|
||||||
const target = Math.max(1, Math.min(props.lastPage, Math.floor(n)));
|
const target = Math.max(1, Math.min(props.lastPage, Math.floor(n)));
|
||||||
if (target !== props.currentPage) setPage(target);
|
if (target !== props.currentPage) props.table.setPageIndex(target - 1);
|
||||||
gotoInput.value = "";
|
gotoInput.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,14 +137,17 @@ function setPage(p) {
|
|||||||
>
|
>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<!-- First -->
|
<!-- First -->
|
||||||
<PaginationFirst :disabled="currentPage <= 1" @click="setPage(1)">
|
<PaginationFirst
|
||||||
|
:disabled="!table.getCanPreviousPage()"
|
||||||
|
@click="table.setPageIndex(0)"
|
||||||
|
>
|
||||||
<ChevronsLeft />
|
<ChevronsLeft />
|
||||||
</PaginationFirst>
|
</PaginationFirst>
|
||||||
|
|
||||||
<!-- Previous -->
|
<!-- Previous -->
|
||||||
<PaginationPrevious
|
<PaginationPrevious
|
||||||
:disabled="currentPage <= 1"
|
:disabled="!table.getCanPreviousPage()"
|
||||||
@click="setPage(currentPage - 1)"
|
@click="table.previousPage()"
|
||||||
>
|
>
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
</PaginationPrevious>
|
</PaginationPrevious>
|
||||||
@@ -154,25 +158,22 @@ function setPage(p) {
|
|||||||
<PaginationItem
|
<PaginationItem
|
||||||
v-else
|
v-else
|
||||||
:value="item"
|
:value="item"
|
||||||
:is-active="currentPage === item"
|
:is-active="currentPage === index"
|
||||||
@click="setPage(item)"
|
@click="table.setPageIndex(index)"
|
||||||
>
|
>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Next -->
|
<!-- Next -->
|
||||||
<PaginationNext
|
<PaginationNext :disabled="!table.getCanNextPage()" @click="table.nextPage()">
|
||||||
:disabled="currentPage >= lastPage"
|
|
||||||
@click="setPage(currentPage + 1)"
|
|
||||||
>
|
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</PaginationNext>
|
</PaginationNext>
|
||||||
|
|
||||||
<!-- Last -->
|
<!-- Last -->
|
||||||
<PaginationLast
|
<PaginationLast
|
||||||
:disabled="currentPage >= lastPage"
|
:disabled="!table.getCanNextPage()"
|
||||||
@click="setPage(lastPage)"
|
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||||
>
|
>
|
||||||
<ChevronsRight />
|
<ChevronsRight />
|
||||||
</PaginationLast>
|
</PaginationLast>
|
||||||
@@ -191,7 +192,7 @@ function setPage(p) {
|
|||||||
:max="lastPage"
|
:max="lastPage"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
:placeholder="String(currentPage)"
|
:placeholder="String(currentPage + 1)"
|
||||||
aria-label="Pojdi na stran"
|
aria-label="Pojdi na stran"
|
||||||
@keyup.enter="goToPageInput"
|
@keyup.enter="goToPageInput"
|
||||||
@blur="goToPageInput"
|
@blur="goToPageInput"
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
import { computed, ref, useAttrs } from "vue";
|
import { computed, ref, useAttrs } from "vue";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Calendar } from "@/Components/ui/calendar";
|
import { Calendar } from "@/Components/ui/calendar";
|
||||||
import {
|
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/Components/ui/popover";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CalendarIcon } from "lucide-vue-next";
|
import { CalendarIcon } from "lucide-vue-next";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@@ -86,7 +82,9 @@ const toCalendarDate = (value) => {
|
|||||||
// Convert CalendarDate to ISO string (YYYY-MM-DD)
|
// Convert CalendarDate to ISO string (YYYY-MM-DD)
|
||||||
const fromCalendarDate = (calendarDate) => {
|
const fromCalendarDate = (calendarDate) => {
|
||||||
if (!calendarDate) return null;
|
if (!calendarDate) return null;
|
||||||
return `${String(calendarDate.year).padStart(4, "0")}-${String(calendarDate.month).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
|
return `${String(calendarDate.year).padStart(4, "0")}-${String(
|
||||||
|
calendarDate.month
|
||||||
|
).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendarDate = computed({
|
const calendarDate = computed({
|
||||||
@@ -142,11 +140,10 @@ const open = ref(false);
|
|||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-auto p-0" align="start">
|
<PopoverContent class="w-auto p-0" align="start">
|
||||||
<Calendar v-model="calendarDate" :disabled="disabled" />
|
<Calendar locale="sl-SI" v-model="calendarDate" :disabled="disabled" />
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<p v-if="error" class="mt-1 text-sm text-red-600">
|
<p v-if="error" class="mt-1 text-sm text-red-600">
|
||||||
{{ Array.isArray(error) ? error[0] : error }}
|
{{ Array.isArray(error) ? error[0] : error }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch, onUnmounted } from "vue";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/Components/ui/dialog";
|
} from "@/Components/ui/dialog";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Badge } from "../ui/badge";
|
import { Badge } from "../ui/badge";
|
||||||
import { Loader2 } from "lucide-vue-next";
|
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -26,6 +26,141 @@ const loading = ref(false);
|
|||||||
const previewGenerating = ref(false);
|
const previewGenerating = ref(false);
|
||||||
const previewError = ref("");
|
const previewError = ref("");
|
||||||
|
|
||||||
|
// Image viewer – zoom & pan state
|
||||||
|
const containerRef = ref(null);
|
||||||
|
const imageRef = ref(null);
|
||||||
|
const imageScale = ref(1);
|
||||||
|
const translateX = ref(0);
|
||||||
|
const translateY = ref(0);
|
||||||
|
const fitScale = ref(1);
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const hasMoved = ref(false);
|
||||||
|
const dragStartX = ref(0);
|
||||||
|
const dragStartY = ref(0);
|
||||||
|
const dragStartTX = ref(0);
|
||||||
|
const dragStartTY = ref(0);
|
||||||
|
|
||||||
|
const MAX_SCALE = 8;
|
||||||
|
|
||||||
|
const imageCursorClass = computed(() => {
|
||||||
|
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
|
||||||
|
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
|
||||||
|
return "cursor-default";
|
||||||
|
});
|
||||||
|
|
||||||
|
const initImageView = () => {
|
||||||
|
const container = containerRef.value;
|
||||||
|
const img = imageRef.value;
|
||||||
|
if (!container || !img) return;
|
||||||
|
const cW = container.clientWidth;
|
||||||
|
const cH = container.clientHeight;
|
||||||
|
const iW = img.naturalWidth || cW;
|
||||||
|
const iH = img.naturalHeight || cH;
|
||||||
|
const fs = Math.min(1, cW / iW, cH / iH);
|
||||||
|
fitScale.value = fs;
|
||||||
|
imageScale.value = fs;
|
||||||
|
translateX.value = (cW - iW * fs) / 2;
|
||||||
|
translateY.value = (cH - iH * fs) / 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetImageView = () => {
|
||||||
|
initImageView();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampTranslate = (tx, ty, scale) => {
|
||||||
|
const container = containerRef.value;
|
||||||
|
const img = imageRef.value;
|
||||||
|
if (!container || !img) return { tx, ty };
|
||||||
|
const cW = container.clientWidth;
|
||||||
|
const cH = container.clientHeight;
|
||||||
|
const iW = img.naturalWidth * scale;
|
||||||
|
const iH = img.naturalHeight * scale;
|
||||||
|
// When image fills the container: clamp so image edges stay within container.
|
||||||
|
// When image is smaller than container: keep it centered.
|
||||||
|
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
|
||||||
|
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
|
||||||
|
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
|
||||||
|
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
|
||||||
|
return {
|
||||||
|
tx: Math.min(maxX, Math.max(minX, tx)),
|
||||||
|
ty: Math.min(maxY, Math.max(minY, ty)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomAt = (mx, my, factor) => {
|
||||||
|
const img = imageRef.value;
|
||||||
|
const iW = img?.naturalWidth ?? 1;
|
||||||
|
const iH = img?.naturalHeight ?? 1;
|
||||||
|
const raw = imageScale.value * factor;
|
||||||
|
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
|
||||||
|
if (newScale === imageScale.value) return;
|
||||||
|
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
|
||||||
|
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
|
||||||
|
const clamped = clampTranslate(tx, ty, newScale);
|
||||||
|
translateX.value = clamped.tx;
|
||||||
|
translateY.value = clamped.ty;
|
||||||
|
imageScale.value = newScale;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mousePos = (e) => {
|
||||||
|
const rect = containerRef.value.getBoundingClientRect();
|
||||||
|
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
initImageView();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const { mx, my } = mousePos(e);
|
||||||
|
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (e) => {
|
||||||
|
if (!isDragging.value) return;
|
||||||
|
const dx = e.clientX - dragStartX.value;
|
||||||
|
const dy = e.clientY - dragStartY.value;
|
||||||
|
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
||||||
|
hasMoved.value = true;
|
||||||
|
}
|
||||||
|
if (hasMoved.value) {
|
||||||
|
const clamped = clampTranslate(
|
||||||
|
dragStartTX.value + dx,
|
||||||
|
dragStartTY.value + dy,
|
||||||
|
imageScale.value
|
||||||
|
);
|
||||||
|
translateX.value = clamped.tx;
|
||||||
|
translateY.value = clamped.ty;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
isDragging.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
hasMoved.value = false;
|
||||||
|
}, 0);
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
isDragging.value = true;
|
||||||
|
hasMoved.value = false;
|
||||||
|
dragStartX.value = e.clientX;
|
||||||
|
dragStartY.value = e.clientY;
|
||||||
|
dragStartTX.value = translateX.value;
|
||||||
|
dragStartTY.value = translateY.value;
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
});
|
||||||
|
|
||||||
const fileExtension = computed(() => {
|
const fileExtension = computed(() => {
|
||||||
if (props.filename) {
|
if (props.filename) {
|
||||||
return props.filename.split(".").pop()?.toLowerCase() || "";
|
return props.filename.split(".").pop()?.toLowerCase() || "";
|
||||||
@@ -118,6 +253,10 @@ watch(
|
|||||||
previewGenerating.value = false;
|
previewGenerating.value = false;
|
||||||
previewError.value = "";
|
previewError.value = "";
|
||||||
docxPreviewUrl.value = "";
|
docxPreviewUrl.value = "";
|
||||||
|
imageScale.value = 1;
|
||||||
|
translateX.value = 0;
|
||||||
|
translateY.value = 0;
|
||||||
|
fitScale.value = 1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -179,11 +318,51 @@ watch(
|
|||||||
|
|
||||||
<!-- Image Viewer -->
|
<!-- Image Viewer -->
|
||||||
<template v-else-if="viewerType === 'image' && props.src">
|
<template v-else-if="viewerType === 'image' && props.src">
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="relative h-full overflow-hidden select-none"
|
||||||
|
:class="imageCursorClass"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
@wheel.prevent="handleWheel"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
|
ref="imageRef"
|
||||||
:src="props.src"
|
:src="props.src"
|
||||||
:alt="props.title"
|
:alt="props.title"
|
||||||
class="max-w-full max-h-full mx-auto object-contain"
|
draggable="false"
|
||||||
|
class="absolute top-0 left-0 max-w-none"
|
||||||
|
:style="{
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
|
||||||
|
transition: isDragging ? 'none' : 'transform 0.12s ease',
|
||||||
|
}"
|
||||||
|
@load="handleImageLoad"
|
||||||
/>
|
/>
|
||||||
|
<!-- Zoom level badge -->
|
||||||
|
<div
|
||||||
|
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
|
||||||
|
>
|
||||||
|
{{ Math.round(imageScale * 100) }}%
|
||||||
|
</div>
|
||||||
|
<!-- Reset button -->
|
||||||
|
<Button
|
||||||
|
v-if="imageScale > fitScale + 0.01"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="secondary"
|
||||||
|
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
|
||||||
|
title="Ponastavi pogled"
|
||||||
|
@click.stop="resetImageView"
|
||||||
|
>
|
||||||
|
<RotateCcwIcon class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<!-- Hint -->
|
||||||
|
<div
|
||||||
|
v-if="imageScale <= fitScale + 0.01"
|
||||||
|
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
|
||||||
|
>
|
||||||
|
Kolesce za povečavo / pomanjšavo · Povleči za premik
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Text/CSV/XML Viewer -->
|
<!-- Text/CSV/XML Viewer -->
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onUnmounted } from "vue";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerClose,
|
||||||
|
} from "@/Components/ui/drawer";
|
||||||
|
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
src: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "Dokument" },
|
||||||
|
mimeType: { type: String, default: "" },
|
||||||
|
filename: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["close"]);
|
||||||
|
|
||||||
|
const textContent = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const previewGenerating = ref(false);
|
||||||
|
const previewError = ref("");
|
||||||
|
|
||||||
|
// Image viewer – zoom & pan state
|
||||||
|
const containerRef = ref(null);
|
||||||
|
const imageRef = ref(null);
|
||||||
|
const imageScale = ref(1);
|
||||||
|
const translateX = ref(0);
|
||||||
|
const translateY = ref(0);
|
||||||
|
const fitScale = ref(1);
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const hasMoved = ref(false);
|
||||||
|
const dragStartX = ref(0);
|
||||||
|
const dragStartY = ref(0);
|
||||||
|
const dragStartTX = ref(0);
|
||||||
|
const dragStartTY = ref(0);
|
||||||
|
|
||||||
|
const MAX_SCALE = 8;
|
||||||
|
|
||||||
|
const imageCursorClass = computed(() => {
|
||||||
|
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
|
||||||
|
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
|
||||||
|
return "cursor-default";
|
||||||
|
});
|
||||||
|
|
||||||
|
const initImageView = () => {
|
||||||
|
const container = containerRef.value;
|
||||||
|
const img = imageRef.value;
|
||||||
|
if (!container || !img) return;
|
||||||
|
const cW = container.clientWidth;
|
||||||
|
const cH = container.clientHeight;
|
||||||
|
const iW = img.naturalWidth || cW;
|
||||||
|
const iH = img.naturalHeight || cH;
|
||||||
|
const fs = Math.min(1, cW / iW, cH / iH);
|
||||||
|
fitScale.value = fs;
|
||||||
|
imageScale.value = fs;
|
||||||
|
translateX.value = (cW - iW * fs) / 2;
|
||||||
|
translateY.value = (cH - iH * fs) / 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetImageView = () => initImageView();
|
||||||
|
|
||||||
|
const clampTranslate = (tx, ty, scale) => {
|
||||||
|
const container = containerRef.value;
|
||||||
|
const img = imageRef.value;
|
||||||
|
if (!container || !img) return { tx, ty };
|
||||||
|
const cW = container.clientWidth;
|
||||||
|
const cH = container.clientHeight;
|
||||||
|
const iW = img.naturalWidth * scale;
|
||||||
|
const iH = img.naturalHeight * scale;
|
||||||
|
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
|
||||||
|
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
|
||||||
|
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
|
||||||
|
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
|
||||||
|
return {
|
||||||
|
tx: Math.min(maxX, Math.max(minX, tx)),
|
||||||
|
ty: Math.min(maxY, Math.max(minY, ty)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomAt = (mx, my, factor) => {
|
||||||
|
const raw = imageScale.value * factor;
|
||||||
|
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
|
||||||
|
if (newScale === imageScale.value) return;
|
||||||
|
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
|
||||||
|
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
|
||||||
|
const clamped = clampTranslate(tx, ty, newScale);
|
||||||
|
translateX.value = clamped.tx;
|
||||||
|
translateY.value = clamped.ty;
|
||||||
|
imageScale.value = newScale;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mousePos = (e) => {
|
||||||
|
const rect = containerRef.value.getBoundingClientRect();
|
||||||
|
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageLoad = () => initImageView();
|
||||||
|
|
||||||
|
const handleWheel = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const { mx, my } = mousePos(e);
|
||||||
|
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch pinch-to-zoom
|
||||||
|
let lastTouchDist = null;
|
||||||
|
let lastTouchMidX = null;
|
||||||
|
let lastTouchMidY = null;
|
||||||
|
|
||||||
|
const getTouchDist = (touches) => {
|
||||||
|
const dx = touches[0].clientX - touches[1].clientX;
|
||||||
|
const dy = touches[0].clientY - touches[1].clientY;
|
||||||
|
return Math.hypot(dx, dy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e) => {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
lastTouchDist = getTouchDist(e.touches);
|
||||||
|
const rect = containerRef.value.getBoundingClientRect();
|
||||||
|
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||||||
|
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.touches.length === 1) {
|
||||||
|
isDragging.value = true;
|
||||||
|
hasMoved.value = false;
|
||||||
|
dragStartX.value = e.touches[0].clientX;
|
||||||
|
dragStartY.value = e.touches[0].clientY;
|
||||||
|
dragStartTX.value = translateX.value;
|
||||||
|
dragStartTY.value = translateY.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e) => {
|
||||||
|
if (e.touches.length === 2 && lastTouchDist !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dist = getTouchDist(e.touches);
|
||||||
|
const factor = dist / lastTouchDist;
|
||||||
|
const rect = containerRef.value.getBoundingClientRect();
|
||||||
|
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||||||
|
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||||||
|
zoomAt(midX, midY, factor);
|
||||||
|
lastTouchDist = dist;
|
||||||
|
lastTouchMidX = midX;
|
||||||
|
lastTouchMidY = midY;
|
||||||
|
} else if (e.touches.length === 1 && isDragging.value) {
|
||||||
|
const dx = e.touches[0].clientX - dragStartX.value;
|
||||||
|
const dy = e.touches[0].clientY - dragStartY.value;
|
||||||
|
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
||||||
|
hasMoved.value = true;
|
||||||
|
}
|
||||||
|
if (hasMoved.value) {
|
||||||
|
const clamped = clampTranslate(
|
||||||
|
dragStartTX.value + dx,
|
||||||
|
dragStartTY.value + dy,
|
||||||
|
imageScale.value
|
||||||
|
);
|
||||||
|
translateX.value = clamped.tx;
|
||||||
|
translateY.value = clamped.ty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
lastTouchDist = null;
|
||||||
|
isDragging.value = false;
|
||||||
|
setTimeout(() => { hasMoved.value = false; }, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (e) => {
|
||||||
|
if (!isDragging.value) return;
|
||||||
|
const dx = e.clientX - dragStartX.value;
|
||||||
|
const dy = e.clientY - dragStartY.value;
|
||||||
|
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) hasMoved.value = true;
|
||||||
|
if (hasMoved.value) {
|
||||||
|
const clamped = clampTranslate(
|
||||||
|
dragStartTX.value + dx,
|
||||||
|
dragStartTY.value + dy,
|
||||||
|
imageScale.value
|
||||||
|
);
|
||||||
|
translateX.value = clamped.tx;
|
||||||
|
translateY.value = clamped.ty;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
isDragging.value = false;
|
||||||
|
setTimeout(() => { hasMoved.value = false; }, 0);
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
isDragging.value = true;
|
||||||
|
hasMoved.value = false;
|
||||||
|
dragStartX.value = e.clientX;
|
||||||
|
dragStartY.value = e.clientY;
|
||||||
|
dragStartTX.value = translateX.value;
|
||||||
|
dragStartTY.value = translateY.value;
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileExtension = computed(() => {
|
||||||
|
if (props.filename) return props.filename.split(".").pop()?.toLowerCase() || "";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewerType = computed(() => {
|
||||||
|
const ext = fileExtension.value;
|
||||||
|
const mime = props.mimeType.toLowerCase();
|
||||||
|
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
||||||
|
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
|
||||||
|
return "docx";
|
||||||
|
if (["jpg", "jpeg", "png", "gif", "webp", "heic", "heif"].includes(ext) || mime.startsWith("image/"))
|
||||||
|
return "image";
|
||||||
|
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
|
||||||
|
return "unsupported";
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadTextContent = async () => {
|
||||||
|
if (!props.src || viewerType.value !== "text") return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get(props.src);
|
||||||
|
textContent.value = response.data;
|
||||||
|
} catch {
|
||||||
|
textContent.value = "Napaka pri nalaganju vsebine.";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const docxPreviewUrl = ref("");
|
||||||
|
const loadDocxPreview = async () => {
|
||||||
|
if (!props.src || viewerType.value !== "docx") return;
|
||||||
|
previewGenerating.value = true;
|
||||||
|
previewError.value = "";
|
||||||
|
docxPreviewUrl.value = "";
|
||||||
|
const maxRetries = 15;
|
||||||
|
const retryDelay = 2000;
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await axios.head(props.src, { validateStatus: () => true });
|
||||||
|
if (response.status >= 200 && response.status < 300) {
|
||||||
|
docxPreviewUrl.value = props.src;
|
||||||
|
previewGenerating.value = false;
|
||||||
|
return;
|
||||||
|
} else if (response.status === 202) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||||
|
} else {
|
||||||
|
previewError.value = "Napaka pri nalaganju predogleda.";
|
||||||
|
previewGenerating.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
previewError.value = "Napaka pri nalaganju predogleda.";
|
||||||
|
previewGenerating.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
|
||||||
|
previewGenerating.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, props.src],
|
||||||
|
([show]) => {
|
||||||
|
if (show && viewerType.value === "text") loadTextContent();
|
||||||
|
if (show && viewerType.value === "docx") loadDocxPreview();
|
||||||
|
if (!show) {
|
||||||
|
previewGenerating.value = false;
|
||||||
|
previewError.value = "";
|
||||||
|
docxPreviewUrl.value = "";
|
||||||
|
imageScale.value = 1;
|
||||||
|
translateX.value = 0;
|
||||||
|
translateY.value = 0;
|
||||||
|
fitScale.value = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Drawer :open="show" @update:open="(val) => !val && emit('close')">
|
||||||
|
<DrawerContent class="flex flex-col h-[95vh]">
|
||||||
|
<DrawerHeader class="border-b px-4 py-3 shrink-0">
|
||||||
|
<DrawerTitle class="truncate pr-4">{{ title }}</DrawerTitle>
|
||||||
|
<div class="mt-1">
|
||||||
|
<Badge>{{ fileExtension }}</Badge>
|
||||||
|
</div>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<!-- Viewer area: flex-1 + min-h-0 works because parent has fixed h-[95vh] -->
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<!-- PDF Viewer -->
|
||||||
|
<template v-if="viewerType === 'pdf' && src">
|
||||||
|
<iframe :src="src" class="w-full h-full" type="application/pdf" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- DOCX Viewer (converted to PDF by backend) -->
|
||||||
|
<template v-else-if="viewerType === 'docx'">
|
||||||
|
<div
|
||||||
|
v-if="previewGenerating"
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-4"
|
||||||
|
>
|
||||||
|
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
|
||||||
|
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="previewError"
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||||||
|
>
|
||||||
|
<span>{{ previewError }}</span>
|
||||||
|
<Button as="a" :href="src" target="_blank" variant="outline">
|
||||||
|
Prenesi datoteko
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
v-else-if="docxPreviewUrl"
|
||||||
|
:src="docxPreviewUrl"
|
||||||
|
class="w-full h-full"
|
||||||
|
type="application/pdf"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Image Viewer with touch pinch-to-zoom -->
|
||||||
|
<template v-else-if="viewerType === 'image' && src">
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="relative h-full overflow-hidden select-none"
|
||||||
|
:class="imageCursorClass"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
@wheel.prevent="handleWheel"
|
||||||
|
@touchstart.prevent="handleTouchStart"
|
||||||
|
@touchmove.prevent="handleTouchMove"
|
||||||
|
@touchend="handleTouchEnd"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref="imageRef"
|
||||||
|
:src="src"
|
||||||
|
:alt="title"
|
||||||
|
draggable="false"
|
||||||
|
class="absolute top-0 left-0 max-w-none"
|
||||||
|
:style="{
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
|
||||||
|
transition: isDragging ? 'none' : 'transform 0.12s ease',
|
||||||
|
}"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
|
||||||
|
>
|
||||||
|
{{ Math.round(imageScale * 100) }}%
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="imageScale > fitScale + 0.01"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="secondary"
|
||||||
|
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
|
||||||
|
title="Ponastavi pogled"
|
||||||
|
@click.stop="resetImageView"
|
||||||
|
>
|
||||||
|
<RotateCcwIcon class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-if="imageScale <= fitScale + 0.01"
|
||||||
|
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
|
||||||
|
>
|
||||||
|
Ščipni za povečavo · Povleči za premik
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Text/CSV/XML Viewer -->
|
||||||
|
<template v-else-if="viewerType === 'text'">
|
||||||
|
<div v-if="loading" class="flex items-center justify-center h-full">
|
||||||
|
<div class="animate-pulse text-gray-500">Nalaganje...</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea v-else class="h-full">
|
||||||
|
<pre
|
||||||
|
class="p-4 bg-gray-50 dark:bg-gray-900 text-sm whitespace-pre-wrap wrap-break-word"
|
||||||
|
>{{ textContent }}</pre
|
||||||
|
>
|
||||||
|
</ScrollArea>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Unsupported -->
|
||||||
|
<template v-else-if="viewerType === 'unsupported'">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||||||
|
>
|
||||||
|
<span>Predogled ni na voljo za to vrsto datoteke.</span>
|
||||||
|
<Button as="a" :href="src" target="_blank" variant="outline">
|
||||||
|
Prenesi datoteko
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="flex items-center justify-center h-full text-sm text-gray-500">
|
||||||
|
Ni dokumenta za prikaz.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerFooter class="border-t shrink-0 px-4 py-3">
|
||||||
|
<DrawerClose as-child>
|
||||||
|
<Button variant="outline" class="w-full" @click="emit('close')">Zapri</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
||||||
import {
|
import {
|
||||||
faLocationDot,
|
MapPin,
|
||||||
faPhone,
|
Phone,
|
||||||
faEnvelope,
|
Mail,
|
||||||
faLandmark,
|
Landmark,
|
||||||
faChevronDown,
|
ChevronDown,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
CheckIcon,
|
||||||
|
CircleCheckIcon,
|
||||||
|
CircleCheckBigIcon,
|
||||||
|
} from "lucide-vue-next";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
|
||||||
|
import Badge from "./ui/badge/Badge.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
person: { type: Object, required: true },
|
person: { type: Object, required: true },
|
||||||
@@ -76,13 +80,6 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function maskIban(iban) {
|
|
||||||
if (!iban || typeof iban !== "string") return null;
|
|
||||||
const clean = iban.replace(/\s+/g, "");
|
|
||||||
if (clean.length <= 8) return clean;
|
|
||||||
return `${clean.slice(0, 4)} **** **** ${clean.slice(-4)}`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -90,11 +87,11 @@ function maskIban(iban) {
|
|||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||||
<span v-if="primaryAddress" class="pill pill-slate" title="Naslov">
|
<span v-if="primaryAddress" class="pill pill-slate" title="Naslov">
|
||||||
<FontAwesomeIcon :icon="faLocationDot" class="w-4 h-4 mr-1" />
|
<MapPin class="w-4 h-4 mr-1" />
|
||||||
<span class="truncate max-w-[9rem]">{{ primaryAddress.address }}</span>
|
<span class="truncate max-w-36">{{ primaryAddress.address }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="summaryPhones.length" class="pill pill-indigo" title="Telefon">
|
<span v-if="summaryPhones.length" class="pill pill-indigo" title="Telefon">
|
||||||
<FontAwesomeIcon :icon="faPhone" class="w-4 h-4 mr-1" />
|
<Phone class="w-4 h-4 mr-1" />
|
||||||
{{ summaryPhones[0].nu
|
{{ summaryPhones[0].nu
|
||||||
}}<span
|
}}<span
|
||||||
v-if="
|
v-if="
|
||||||
@@ -108,12 +105,12 @@ function maskIban(iban) {
|
|||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="primaryEmail && showMore" class="pill pill-default" title="E-pošta">
|
<span v-if="primaryEmail && showMore" class="pill pill-default" title="E-pošta">
|
||||||
<FontAwesomeIcon :icon="faEnvelope" class="w-4 h-4 mr-1" />
|
<Mail class="w-4 h-4 mr-1" />
|
||||||
<span class="truncate max-w-[9rem]">{{ primaryEmail }}</span>
|
<span class="truncate max-w-36">{{ primaryEmail }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="bankIban" class="pill pill-emerald" title="TRR (zadnji dodan)">
|
<span v-if="bankIban" class="pill pill-emerald" title="TRR (zadnji dodan)">
|
||||||
<FontAwesomeIcon :icon="faLandmark" class="w-4 h-4 mr-1" />
|
<Landmark class="w-4 h-4 mr-1" />
|
||||||
{{ maskIban(bankIban) }}
|
{{ bankIban }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,7 +126,7 @@ function maskIban(iban) {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="bankIban">
|
<div v-if="bankIban">
|
||||||
<div class="label">TRR (zadnji)</div>
|
<div class="label">TRR (zadnji)</div>
|
||||||
<div class="value font-mono">{{ maskIban(bankIban) }}</div>
|
<div class="value font-mono">{{ bankIban }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="primaryEmail">
|
<div v-if="primaryEmail">
|
||||||
<div class="label">E‑pošta</div>
|
<div class="label">E‑pošta</div>
|
||||||
@@ -142,8 +139,7 @@ function maskIban(iban) {
|
|||||||
class="mt-3 inline-flex items-center text-[11px] font-medium text-indigo-600 hover:text-indigo-700 focus:outline-none"
|
class="mt-3 inline-flex items-center text-[11px] font-medium text-indigo-600 hover:text-indigo-700 focus:outline-none"
|
||||||
@click="showMore = !showMore"
|
@click="showMore = !showMore"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<ChevronDown
|
||||||
:icon="faChevronDown"
|
|
||||||
:class="[
|
:class="[
|
||||||
'w-3 h-3 mr-1 transition-transform',
|
'w-3 h-3 mr-1 transition-transform',
|
||||||
showMore ? 'rotate-180' : 'rotate-0',
|
showMore ? 'rotate-180' : 'rotate-0',
|
||||||
@@ -154,83 +150,91 @@ function maskIban(iban) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Segmented Tabs -->
|
<!-- Segmented Tabs -->
|
||||||
<div class="mt-5">
|
<div class="mt-4 text-sm">
|
||||||
<div class="relative">
|
<Tabs :default-value="activeTab" @update:model-value="activeTab = $event">
|
||||||
<div
|
<TabsList class="w-full">
|
||||||
class="flex w-full text-[11px] font-medium rounded-lg border bg-gray-50 overflow-hidden"
|
<TabsTrigger value="addresses" class="flex-1">
|
||||||
>
|
<div class="flex flex-row items-center gap-1">
|
||||||
<button
|
<MapPin class="w-3.5 h-3.5 shrink-0" />
|
||||||
type="button"
|
|
||||||
@click="activeTab = 'addresses'"
|
|
||||||
:class="['seg-btn', activeTab === 'addresses' && 'seg-active']"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon :icon="faLocationDot" class="w-3.5 h-3.5 mr-1 shrink-0" />
|
|
||||||
<span class="truncate">Naslovi ({{ allAddresses.length }})</span>
|
<span class="truncate">Naslovi ({{ allAddresses.length }})</span>
|
||||||
</button>
|
</div>
|
||||||
<button
|
</TabsTrigger>
|
||||||
type="button"
|
<TabsTrigger value="phones" class="flex-1">
|
||||||
@click="activeTab = 'phones'"
|
<div class="flex flex-row items-center gap-1">
|
||||||
:class="['seg-btn', activeTab === 'phones' && 'seg-active']"
|
<Phone class="w-3.5 h-3.5 shrink-0" />
|
||||||
>
|
|
||||||
<FontAwesomeIcon :icon="faPhone" class="w-3.5 h-3.5 mr-1 shrink-0" />
|
|
||||||
<span class="truncate">Telefoni ({{ allPhones.length }})</span>
|
<span class="truncate">Telefoni ({{ allPhones.length }})</span>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="activeTab = 'emails'"
|
|
||||||
:class="['seg-btn', activeTab === 'emails' && 'seg-active']"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon :icon="faEnvelope" class="w-3.5 h-3.5 mr-1 shrink-0" />
|
|
||||||
<span class="truncate">E‑pošta ({{ allEmails.length }})</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="emails" class="flex-1">
|
||||||
|
<div class="flex flex-row items-center gap-1">
|
||||||
|
<Mail class="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span class="truncate">E‑pošta ({{ allEmails.length }})</span>
|
||||||
</div>
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<div class="mt-3 rounded-md border bg-white/60 p-2">
|
<TabsContent value="addresses" class="mt-2 rounded-md border">
|
||||||
<!-- Addresses -->
|
<div v-if="!allAddresses.length" class="p-2 text-center">Ni naslovov.</div>
|
||||||
<div v-if="activeTab === 'addresses'">
|
<div
|
||||||
<div v-if="!allAddresses.length" class="empty">Ni naslovov.</div>
|
v-for="(a, idx) in allAddresses"
|
||||||
<div v-for="(a, idx) in allAddresses" :key="a.id || idx" class="item-row">
|
:key="a.id || idx"
|
||||||
<div class="font-medium text-gray-800">{{ a.address }}</div>
|
class="flex flex-row gap-1 items-center p-2 border-b last:border-0"
|
||||||
<div v-if="a.country" class="sub">{{ a.country }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Phones -->
|
|
||||||
<div v-else-if="activeTab === 'phones'">
|
|
||||||
<div v-if="!allPhones.length" class="empty">Ni telefonov.</div>
|
|
||||||
<div v-for="(p, idx) in allPhones" :key="p.id || idx" class="item-row">
|
|
||||||
<div class="font-medium text-gray-800">
|
|
||||||
{{ p.nu }}
|
|
||||||
<span
|
|
||||||
v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name"
|
|
||||||
class="sub ml-1"
|
|
||||||
>({{ p.type?.name || phoneTypes[p.type_id] }})</span
|
|
||||||
>
|
>
|
||||||
|
<p class="font-bold wrap-break-word max-w-60">{{ a.address }}</p>
|
||||||
|
<Badge v-if="a.country" variant="outline" class="text-xs">{{
|
||||||
|
a.country
|
||||||
|
}}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="phones" class="mt-2 rounded-md border">
|
||||||
|
<div v-if="!allPhones.length" class="p-2 text-center">Ni telefonov.</div>
|
||||||
|
<div
|
||||||
|
v-for="(p, idx) in allPhones"
|
||||||
|
:key="p.id || idx"
|
||||||
|
class="flex flex-row gap-1 items-center p-2 border-b last:border-0"
|
||||||
|
>
|
||||||
|
<p class="font-bold wrap-break-word max-w-60">{{ p.nu }}</p>
|
||||||
|
|
||||||
|
<CircleCheckBigIcon v-if="p.validated" class="text-green-500" :size="16" />
|
||||||
|
<Badge variant="outline" v-if="p.label" class="text-xs font-medium">{{
|
||||||
|
p.label
|
||||||
|
}}</Badge>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name"
|
||||||
|
>
|
||||||
|
{{ p.type?.name || phoneTypes[p.type_id] }}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="emails" class="mt-2 rounded-md border">
|
||||||
|
<div v-if="!allEmails.length" class="p-2 text-center">Ni e-poštnih naslovov.</div>
|
||||||
|
<div
|
||||||
|
v-for="(e, idx) in allEmails"
|
||||||
|
:key="e.id || idx"
|
||||||
|
class="flex flex-row gap-1 items-center p-2 border-b last:border-0"
|
||||||
|
>
|
||||||
|
<p class="font-bold wrap-break-word max-w-60">
|
||||||
|
{{ e.value }}
|
||||||
|
</p>
|
||||||
|
<CircleCheckBigIcon v-if="e.valid" class="text-green-500" :size="16" />
|
||||||
|
<Badge v-if="e.label" variant="outline">({{ e.label }})</Badge>
|
||||||
</div>
|
</div>
|
||||||
<!-- Emails -->
|
</TabsContent>
|
||||||
<div v-else-if="activeTab === 'emails'">
|
</Tabs>
|
||||||
<div v-if="!allEmails.length" class="empty">Ni e-poštnih naslovov.</div>
|
|
||||||
<div v-for="(e, idx) in allEmails" :key="e.id || idx" class="item-row">
|
|
||||||
<div class="font-medium text-gray-800">
|
|
||||||
{{ e.value }}<span v-if="e.label" class="sub ml-1">({{ e.label }})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- (TRR tab removed; last bank account surfaced in summary) -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Basic utility replacements (no Tailwind processor here) */
|
|
||||||
.pill {
|
.pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
padding: 0.35rem 0.75rem; /* slightly larger */
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
@@ -253,36 +257,6 @@ function maskIban(iban) {
|
|||||||
color: #047857;
|
color: #047857;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seg-btn {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0; /* allow flex item to shrink below intrinsic size */
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 0.5rem 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
border-right: 1px solid #e5e7eb;
|
|
||||||
font-size: 11px;
|
|
||||||
background: transparent;
|
|
||||||
color: #4b5563;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.seg-btn:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
.seg-btn:hover {
|
|
||||||
background: #ffffffb3;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
.seg-active {
|
|
||||||
background: #fff;
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: inset 0 0 0 1px #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-row {
|
.item-row {
|
||||||
padding: 0.375rem 0;
|
padding: 0.375rem 0;
|
||||||
border-bottom: 1px dashed #e5e7eb;
|
border-bottom: 1px dashed #e5e7eb;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { computed, ref, watch } from "vue";
|
|||||||
import { useForm, Field as FormField } from "vee-validate";
|
import { useForm, Field as FormField } from "vee-validate";
|
||||||
import { toTypedSchema } from "@vee-validate/zod";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { router } from "@inertiajs/vue3";
|
import { router, usePage } from "@inertiajs/vue3";
|
||||||
|
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||||
import SectionTitle from "../SectionTitle.vue";
|
import SectionTitle from "../SectionTitle.vue";
|
||||||
@@ -27,12 +28,24 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(["close"]);
|
const emit = defineEmits(["close"]);
|
||||||
|
|
||||||
|
// Decisions with auto_mail = true from shared Inertia data
|
||||||
|
const page = usePage();
|
||||||
|
const decisionOptions = computed(() =>
|
||||||
|
(page.props.auto_mail_decisions ?? []).map((d) => ({
|
||||||
|
value: String(d.id),
|
||||||
|
label: d.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// Zod schema for form validation
|
// Zod schema for form validation
|
||||||
const formSchema = toTypedSchema(
|
const formSchema = toTypedSchema(
|
||||||
z.object({
|
z.object({
|
||||||
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
|
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
receive_auto_mails: z.boolean().optional(),
|
receive_auto_mails: z.boolean().optional(),
|
||||||
|
valid: z.boolean().default(true),
|
||||||
|
failed: z.boolean().default(false),
|
||||||
|
decision_ids: z.array(z.string()).optional().default([]),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -43,9 +56,15 @@ const form = useForm({
|
|||||||
value: "",
|
value: "",
|
||||||
label: "",
|
label: "",
|
||||||
receive_auto_mails: false,
|
receive_auto_mails: false,
|
||||||
|
valid: true,
|
||||||
|
failed: false,
|
||||||
|
decision_ids: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whether to limit sending to specific decisions (UI-only toggle)
|
||||||
|
const limitToDecisions = ref(false);
|
||||||
|
|
||||||
const processing = ref(false);
|
const processing = ref(false);
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -57,22 +76,46 @@ const close = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
|
limitToDecisions.value = false;
|
||||||
form.resetForm({
|
form.resetForm({
|
||||||
values: {
|
values: {
|
||||||
value: "",
|
value: "",
|
||||||
label: "",
|
label: "",
|
||||||
receive_auto_mails: false,
|
receive_auto_mails: false,
|
||||||
|
valid: true,
|
||||||
|
failed: false,
|
||||||
|
decision_ids: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// When auto mails is disabled, collapse the decision filter
|
||||||
|
watch(
|
||||||
|
() => form.values.receive_auto_mails,
|
||||||
|
(val) => {
|
||||||
|
if (!val) {
|
||||||
|
limitToDecisions.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// When limit toggle is turned off, clear the selection
|
||||||
|
watch(limitToDecisions, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
form.setFieldValue("decision_ids", []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
const { values } = form;
|
const payload = {
|
||||||
|
...form.values,
|
||||||
|
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
|
||||||
|
};
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
route("person.email.create", props.person),
|
route("person.email.create", props.person),
|
||||||
values,
|
payload,
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -98,11 +141,14 @@ const create = async () => {
|
|||||||
|
|
||||||
const update = async () => {
|
const update = async () => {
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
const { values } = form;
|
const payload = {
|
||||||
|
...form.values,
|
||||||
|
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
|
||||||
|
};
|
||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
route("person.email.update", { person: props.person, email_id: props.id }),
|
route("person.email.update", { person: props.person, email_id: props.id }),
|
||||||
values,
|
payload,
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -136,10 +182,15 @@ watch(
|
|||||||
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
|
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
|
||||||
const email = list.find((e) => e.id === props.id);
|
const email = list.find((e) => e.id === props.id);
|
||||||
if (email) {
|
if (email) {
|
||||||
|
const existingDecisionIds = (email.preferences?.decision_ids ?? []).map(String);
|
||||||
|
limitToDecisions.value = existingDecisionIds.length > 0;
|
||||||
form.setValues({
|
form.setValues({
|
||||||
value: email.value ?? email.email ?? email.address ?? "",
|
value: email.value ?? email.email ?? email.address ?? "",
|
||||||
label: email.label ?? "",
|
label: email.label ?? "",
|
||||||
receive_auto_mails: !!email.receive_auto_mails,
|
receive_auto_mails: !!email.receive_auto_mails,
|
||||||
|
valid: email.valid !== undefined ? !!email.valid : true,
|
||||||
|
failed: !!email.failed,
|
||||||
|
decision_ids: existingDecisionIds,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -228,6 +279,58 @@ const onConfirm = () => {
|
|||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="valid">
|
||||||
|
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Switch :model-value="value" @update:model-value="handleChange" />
|
||||||
|
</FormControl>
|
||||||
|
<div class="space-y-1 leading-none">
|
||||||
|
<FormLabel class="cursor-pointer">Veljavna</FormLabel>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="failed">
|
||||||
|
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Switch :model-value="value" @update:model-value="handleChange" />
|
||||||
|
</FormControl>
|
||||||
|
<div class="space-y-1 leading-none">
|
||||||
|
<FormLabel class="cursor-pointer">Neuspešna dostava</FormLabel>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Limit to specific decisions — only shown when receive_auto_mails is on and decisions exist -->
|
||||||
|
<template v-if="(props.person?.client || isClientContext) && form.values.receive_auto_mails && decisionOptions.length > 0">
|
||||||
|
<div class="flex flex-row items-start space-x-3 space-y-0">
|
||||||
|
<Switch
|
||||||
|
:model-value="limitToDecisions"
|
||||||
|
@update:model-value="(val) => (limitToDecisions = val)"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1 leading-none">
|
||||||
|
<label class="text-sm font-medium leading-none cursor-pointer" @click="limitToDecisions = !limitToDecisions">
|
||||||
|
Omeji na posamezne odločitve
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField v-if="limitToDecisions" v-slot="{ value, handleChange }" name="decision_ids">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Odločitve, za katere se pošlje e-pošta</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<AppMultiSelect
|
||||||
|
:model-value="value ?? []"
|
||||||
|
:items="decisionOptions"
|
||||||
|
placeholder="Izberi odločitve..."
|
||||||
|
@update:model-value="handleChange"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed, nextTick } from "vue";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/Components/ui/dialog";
|
||||||
|
import { router, usePage } from "@inertiajs/vue3";
|
||||||
|
import { useForm, Field as FormField } from "vee-validate";
|
||||||
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Textarea } from "@/Components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/Components/ui/select";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
email: { type: Object, default: null },
|
||||||
|
clientCaseUuid: { type: String, default: null },
|
||||||
|
emailTemplates: { type: Array, default: () => [] },
|
||||||
|
mailProfiles: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close"]);
|
||||||
|
|
||||||
|
const page = usePage();
|
||||||
|
const pageProps = computed(() => page?.props ?? {});
|
||||||
|
|
||||||
|
const pageEmailTemplates = computed(() => {
|
||||||
|
const fromProps =
|
||||||
|
Array.isArray(props.emailTemplates) && props.emailTemplates.length
|
||||||
|
? props.emailTemplates
|
||||||
|
: null;
|
||||||
|
return fromProps ?? pageProps.value?.email_templates ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageMailProfiles = computed(() => {
|
||||||
|
const fromProps =
|
||||||
|
Array.isArray(props.mailProfiles) && props.mailProfiles.length
|
||||||
|
? props.mailProfiles
|
||||||
|
: null;
|
||||||
|
return fromProps ?? pageProps.value?.mail_profiles ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form schema
|
||||||
|
const formSchema = toTypedSchema(
|
||||||
|
z.object({
|
||||||
|
subject: z.string().min(1, "Zadeva je obvezna.").max(255),
|
||||||
|
html_body: z.string().nullable().optional(),
|
||||||
|
body_text: z.string().max(10000).nullable().optional(),
|
||||||
|
template_id: z.number().nullable().optional(),
|
||||||
|
mail_profile_id: z.number().nullable().optional(),
|
||||||
|
contract_uuid: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: formSchema,
|
||||||
|
initialValues: {
|
||||||
|
subject: "",
|
||||||
|
html_body: "",
|
||||||
|
body_text: "",
|
||||||
|
template_id: null,
|
||||||
|
mail_profile_id: null,
|
||||||
|
contract_uuid: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const processing = ref(false);
|
||||||
|
const contractsForCase = ref([]);
|
||||||
|
const hasBodyText = ref(false); // whether selected template uses {{body_text}}
|
||||||
|
|
||||||
|
// WYSIWYG iframe
|
||||||
|
const iframeRef = ref(null);
|
||||||
|
let iframeSyncing = false;
|
||||||
|
|
||||||
|
function ensureFullDoc(html) {
|
||||||
|
if (!html) {
|
||||||
|
return '<!doctype html><html><head><meta charset="utf-8" /></head><body></body></html>';
|
||||||
|
}
|
||||||
|
if (/<html[\s\S]*<\/html>/i.test(html)) return html;
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8" /></head><body>${html}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIframeDocument(html) {
|
||||||
|
const iframe = iframeRef.value;
|
||||||
|
if (!iframe) return;
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
const full = ensureFullDoc(html ?? form.values.html_body ?? "");
|
||||||
|
doc.open();
|
||||||
|
doc.write(full);
|
||||||
|
doc.close();
|
||||||
|
try {
|
||||||
|
doc.body.setAttribute("spellcheck", "false");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initIframeEditor(html) {
|
||||||
|
writeIframeDocument(html);
|
||||||
|
const iframe = iframeRef.value;
|
||||||
|
if (!iframe) return;
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
try {
|
||||||
|
doc.designMode = "on";
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const syncHandler = () => {
|
||||||
|
if (iframeSyncing) return;
|
||||||
|
try {
|
||||||
|
iframeSyncing = true;
|
||||||
|
const full = doc.documentElement.outerHTML;
|
||||||
|
form.setFieldValue("html_body", full);
|
||||||
|
} finally {
|
||||||
|
iframeSyncing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
doc.removeEventListener("input", syncHandler);
|
||||||
|
doc.removeEventListener("keyup", syncHandler);
|
||||||
|
doc.addEventListener("input", syncHandler);
|
||||||
|
doc.addEventListener("keyup", syncHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
function iframeExec(command) {
|
||||||
|
const iframe = iframeRef.value;
|
||||||
|
if (!iframe) return;
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
try {
|
||||||
|
doc.body.focus();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
doc.execCommand(command, false, null);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("execCommand failed", command, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load template preview from server
|
||||||
|
const loadingPreview = ref(false);
|
||||||
|
|
||||||
|
const updateFromTemplate = async () => {
|
||||||
|
if (!form.values.template_id || !props.clientCaseUuid) return;
|
||||||
|
loadingPreview.value = true;
|
||||||
|
try {
|
||||||
|
const url = route("clientCase.email.preview", {
|
||||||
|
client_case: props.clientCaseUuid,
|
||||||
|
email_id: props.email?.id,
|
||||||
|
});
|
||||||
|
const { data } = await axios.post(url, {
|
||||||
|
template_id: form.values.template_id,
|
||||||
|
contract_uuid: form.values.contract_uuid || null,
|
||||||
|
body_text: form.values.body_text || "",
|
||||||
|
});
|
||||||
|
const hadBodyText = hasBodyText.value;
|
||||||
|
hasBodyText.value = !!data?.has_body_text;
|
||||||
|
// Pre-fill body_text from text_template when the placeholder is present and field is empty
|
||||||
|
if (data?.has_body_text && !hadBodyText) {
|
||||||
|
const tpl = pageEmailTemplates.value.find((t) => t.id === form.values.template_id);
|
||||||
|
if (tpl?.text_template && !form.values.body_text) {
|
||||||
|
form.setFieldValue("body_text", tpl.text_template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data?.subject) {
|
||||||
|
form.setFieldValue("subject", data.subject);
|
||||||
|
}
|
||||||
|
const html = data?.html ?? "";
|
||||||
|
form.setFieldValue("html_body", html);
|
||||||
|
await nextTick();
|
||||||
|
initIframeEditor(html);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loadingPreview.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.values.template_id,
|
||||||
|
() => {
|
||||||
|
updateFromTemplate();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.values.contract_uuid,
|
||||||
|
() => {
|
||||||
|
if (form.values.template_id) {
|
||||||
|
updateFromTemplate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-preview when body_text changes (debounce-like: only when a template is active)
|
||||||
|
watch(
|
||||||
|
() => form.values.body_text,
|
||||||
|
() => {
|
||||||
|
if (form.values.template_id && hasBodyText.value) {
|
||||||
|
updateFromTemplate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadContractsForCase = async () => {
|
||||||
|
try {
|
||||||
|
const url = route("clientCase.contracts.list", {
|
||||||
|
client_case: props.clientCaseUuid,
|
||||||
|
});
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
contractsForCase.value = Array.isArray(json?.data) ? json.data : [];
|
||||||
|
} catch (e) {
|
||||||
|
contractsForCase.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
async (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
form.resetForm({
|
||||||
|
values: {
|
||||||
|
subject: "",
|
||||||
|
html_body: "",
|
||||||
|
body_text: "",
|
||||||
|
template_id: null,
|
||||||
|
mail_profile_id: pageMailProfiles.value?.[0]?.id ?? null,
|
||||||
|
contract_uuid: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
hasBodyText.value = false;
|
||||||
|
contractsForCase.value = [];
|
||||||
|
await loadContractsForCase();
|
||||||
|
// Init empty iframe
|
||||||
|
await nextTick();
|
||||||
|
initIframeEditor("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
|
if (!props.email || !props.clientCaseUuid) return;
|
||||||
|
processing.value = true;
|
||||||
|
router.post(
|
||||||
|
route("clientCase.email.send", {
|
||||||
|
client_case: props.clientCaseUuid,
|
||||||
|
email_id: props.email.id,
|
||||||
|
}),
|
||||||
|
values,
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
processing.value = false;
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (value) => {
|
||||||
|
if (!value) closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="open">
|
||||||
|
<DialogContent class="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Pošlji e-pošto</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Prejemnik:
|
||||||
|
<span class="font-mono">{{ email?.value || email?.email || email?.address }}</span>
|
||||||
|
</p>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea class="max-h-[70vh] pr-1">
|
||||||
|
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
|
||||||
|
<!-- Mail profile -->
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="mail_profile_id">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>E-poštni profil</FormLabel>
|
||||||
|
<Select :model-value="value" @update:model-value="handleChange">
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="—" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="null">—</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="p in pageMailProfiles"
|
||||||
|
:key="p.id"
|
||||||
|
:value="p.id"
|
||||||
|
>
|
||||||
|
{{ p.name || "Profil #" + p.id }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Contract -->
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Pogodba</FormLabel>
|
||||||
|
<Select :model-value="value" @update:model-value="handleChange">
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="—" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="null">—</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="c in contractsForCase"
|
||||||
|
:key="c.uuid"
|
||||||
|
:value="c.uuid"
|
||||||
|
>
|
||||||
|
{{ c.reference || c.uuid }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Izberite pogodbo za zapolnitev spremenljivk v predlogi.
|
||||||
|
</p>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Template -->
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="template_id">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Predloga</FormLabel>
|
||||||
|
<Select
|
||||||
|
:model-value="value"
|
||||||
|
@update:model-value="handleChange"
|
||||||
|
:disabled="loadingPreview"
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="—" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="null">—</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="t in pageEmailTemplates"
|
||||||
|
:key="t.id"
|
||||||
|
:value="t.id"
|
||||||
|
>
|
||||||
|
{{ t.name || "Predloga #" + t.id }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Subject -->
|
||||||
|
<FormField v-slot="{ componentField }" name="subject">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Zadeva</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Zadeva e-poštnega sporočila..."
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- body_text textarea — shown only when the template uses {{body_text}} -->
|
||||||
|
<FormField v-if="hasBodyText" v-slot="{ componentField }" name="body_text">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Besedilo sporočila</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Vnesite besedilo, ki se vstavi na mesto {{body_text}} v predlogi..."
|
||||||
|
class="min-h-[120px] resize-y"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Besedilo se vstavi na oznako <code>{{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";
|
} from "@/Components/ui/dropdown-menu";
|
||||||
import { Card } from "@/Components/ui/card";
|
import { Card } from "@/Components/ui/card";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { EllipsisVertical } from "lucide-vue-next";
|
import { CircleCheckBigIcon, CircleXIcon, EllipsisVertical, MailIcon } from "lucide-vue-next";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
person: Object,
|
person: Object,
|
||||||
edit: { type: Boolean, default: true },
|
edit: { type: Boolean, default: true },
|
||||||
|
enableEmail: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["add", "edit", "delete"]);
|
const emit = defineEmits(["add", "edit", "delete", "email"]);
|
||||||
|
|
||||||
const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []);
|
const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []);
|
||||||
|
|
||||||
@@ -44,6 +46,16 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="edit">
|
<div v-if="edit">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="enableEmail"
|
||||||
|
@click="$emit('email', email)"
|
||||||
|
title="Pošlji e-pošto"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<MailIcon :size="18" />
|
||||||
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="icon" title="Možnosti">
|
<Button variant="ghost" size="icon" title="Možnosti">
|
||||||
@@ -68,9 +80,26 @@ const handleDelete = (id, label) => emit("delete", id, label);
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="p-1">
|
<div class="p-1">
|
||||||
<p class="font-medium text-gray-900 leading-relaxed">
|
<p class="font-medium text-gray-900 leading-relaxed flex gap-1 items-center">
|
||||||
{{ email?.value || email?.email || email?.address || "-" }}
|
{{ email?.value || email?.email || email?.address || "-" }}
|
||||||
|
<TooltipProvider v-if="email?.valid">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<CircleCheckBigIcon color="#3e9392" :size="18" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Veljavna</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider v-if="email?.failed">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<CircleXIcon color="#dc2626" :size="18" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Neuspešna dostava</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="email?.note"
|
v-if="email?.note"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
|
|||||||
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
|
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
|
||||||
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
|
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
|
||||||
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
|
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
|
||||||
|
import PersonInfoEmailDialog from "./PersonInfoEmailDialog.vue";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -58,6 +59,9 @@ const props = defineProps({
|
|||||||
smsProfiles: { type: Array, default: () => [] },
|
smsProfiles: { type: Array, default: () => [] },
|
||||||
smsSenders: { type: Array, default: () => [] },
|
smsSenders: { type: Array, default: () => [] },
|
||||||
smsTemplates: { type: Array, default: () => [] },
|
smsTemplates: { type: Array, default: () => [] },
|
||||||
|
enableEmail: { type: Boolean, default: false },
|
||||||
|
emailTemplates: { type: Array, default: () => [] },
|
||||||
|
mailProfiles: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dialog states
|
// Dialog states
|
||||||
@@ -91,6 +95,10 @@ const confirm = ref({
|
|||||||
const showSmsDialog = ref(false);
|
const showSmsDialog = ref(false);
|
||||||
const smsTargetPhone = ref(null);
|
const smsTargetPhone = ref(null);
|
||||||
|
|
||||||
|
// Email dialog state
|
||||||
|
const showEmailDialog = ref(false);
|
||||||
|
const emailTarget = ref(null);
|
||||||
|
|
||||||
// Person handlers
|
// Person handlers
|
||||||
const openDrawerUpdateClient = () => {
|
const openDrawerUpdateClient = () => {
|
||||||
drawerUpdatePerson.value = true;
|
drawerUpdatePerson.value = true;
|
||||||
@@ -251,6 +259,18 @@ const closeSmsDialog = () => {
|
|||||||
smsTargetPhone.value = null;
|
smsTargetPhone.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Email dialog handlers
|
||||||
|
const openEmailDialog = (email) => {
|
||||||
|
if (!props.enableEmail || !props.clientCaseUuid) return;
|
||||||
|
emailTarget.value = email;
|
||||||
|
showEmailDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEmailDialog = () => {
|
||||||
|
showEmailDialog.value = false;
|
||||||
|
emailTarget.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
// Tab event handlers
|
// Tab event handlers
|
||||||
const handlePersonEdit = () => openDrawerUpdateClient();
|
const handlePersonEdit = () => openDrawerUpdateClient();
|
||||||
|
|
||||||
@@ -266,6 +286,7 @@ const handlePhoneSms = (phone) => openSmsDialog(phone);
|
|||||||
const handleEmailAdd = () => openDrawerAddEmail(false, 0);
|
const handleEmailAdd = () => openDrawerAddEmail(false, 0);
|
||||||
const handleEmailEdit = (id) => openDrawerAddEmail(true, id);
|
const handleEmailEdit = (id) => openDrawerAddEmail(true, id);
|
||||||
const handleEmailDelete = (id, label) => openConfirm("email", id, label);
|
const handleEmailDelete = (id, label) => openConfirm("email", id, label);
|
||||||
|
const handleEmailSend = (email) => openEmailDialog(email);
|
||||||
|
|
||||||
const handleTrrAdd = () => openDrawerAddTrr(false, 0);
|
const handleTrrAdd = () => openDrawerAddTrr(false, 0);
|
||||||
const handleTrrEdit = (id) => openDrawerAddTrr(true, id);
|
const handleTrrEdit = (id) => openDrawerAddTrr(true, id);
|
||||||
@@ -418,9 +439,11 @@ const switchToTab = (tab) => {
|
|||||||
<PersonInfoEmailsTab
|
<PersonInfoEmailsTab
|
||||||
:person="person"
|
:person="person"
|
||||||
:edit="edit"
|
:edit="edit"
|
||||||
|
:enable-email="enableEmail && !!clientCaseUuid"
|
||||||
@add="handleEmailAdd"
|
@add="handleEmailAdd"
|
||||||
@edit="handleEmailEdit"
|
@edit="handleEmailEdit"
|
||||||
@delete="handleEmailDelete"
|
@delete="handleEmailDelete"
|
||||||
|
@email="handleEmailSend"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -534,4 +557,15 @@ const switchToTab = (tab) => {
|
|||||||
:sms-templates="smsTemplates"
|
:sms-templates="smsTemplates"
|
||||||
@close="closeSmsDialog"
|
@close="closeSmsDialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Email Dialog -->
|
||||||
|
<PersonInfoEmailDialog
|
||||||
|
v-if="clientCaseUuid"
|
||||||
|
:show="showEmailDialog"
|
||||||
|
:email="emailTarget"
|
||||||
|
:client-case-uuid="clientCaseUuid"
|
||||||
|
:email-templates="emailTemplates"
|
||||||
|
:mail-profiles="mailProfiles"
|
||||||
|
@close="closeEmailDialog"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "@/Components/ui/select";
|
} from "@/Components/ui/select";
|
||||||
import { Switch } from "@/Components/ui/switch";
|
import { Switch } from "@/Components/ui/switch";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: { type: Boolean, default: false },
|
show: { type: Boolean, default: false },
|
||||||
@@ -452,7 +453,8 @@ const open = computed({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form @submit.prevent="onSubmit" class="space-y-4">
|
<ScrollArea class="max-h-[65vh] pr-1">
|
||||||
|
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormField v-slot="{ value, handleChange }" name="profile_id">
|
<FormField v-slot="{ value, handleChange }" name="profile_id">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
@@ -582,8 +584,8 @@ const open = computed({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[11px] text-gray-500 leading-snug">
|
<p class="text-[11px] text-gray-500 leading-snug">
|
||||||
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake, ki
|
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake,
|
||||||
ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
|
ki ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
|
||||||
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS‑2). V tem
|
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
|
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 67 znakov na del), medtem ko je pri GSM‑7 160 znakov (pri daljših
|
||||||
@@ -604,6 +606,7 @@ const open = computed({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
</form>
|
</form>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="closeSmsDialog" :disabled="processing">
|
<Button variant="outline" @click="closeSmsDialog" :disabled="processing">
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ const summaryText = computed(() => {
|
|||||||
const found = props.items.find((i) => String(i.value) === String(v));
|
const found = props.items.find((i) => String(i.value) === String(v));
|
||||||
return found?.label || v;
|
return found?.label || v;
|
||||||
});
|
});
|
||||||
if (labels.length <= 3) return labels.join(', ');
|
if (labels.length <= 3) return labels.join(", ");
|
||||||
const firstThree = labels.slice(0, 3).join(', ');
|
const firstThree = labels.slice(0, 3).join(", ");
|
||||||
const remaining = labels.length - 3;
|
const remaining = labels.length - 3;
|
||||||
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
|
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
|
||||||
});
|
});
|
||||||
@@ -154,7 +154,7 @@ const summaryText = computed(() => {
|
|||||||
:variant="chipVariant"
|
:variant="chipVariant"
|
||||||
class="flex items-center gap-1"
|
class="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span class="truncate max-w-[140px]">
|
<span class="truncate max-w-35">
|
||||||
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
|
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useForwardPropsEmits } from "reka-ui";
|
||||||
|
import { DrawerRoot } from "vaul-vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeSnapPoint: { type: [Number, String, null], required: false },
|
||||||
|
closeThreshold: { type: Number, required: false },
|
||||||
|
shouldScaleBackground: { type: Boolean, required: false, default: true },
|
||||||
|
setBackgroundColorOnScale: { type: Boolean, required: false },
|
||||||
|
scrollLockTimeout: { type: Number, required: false },
|
||||||
|
fixed: { type: Boolean, required: false },
|
||||||
|
dismissible: { type: Boolean, required: false },
|
||||||
|
modal: { type: Boolean, required: false },
|
||||||
|
open: { type: Boolean, required: false },
|
||||||
|
defaultOpen: { type: Boolean, required: false },
|
||||||
|
nested: { type: Boolean, required: false },
|
||||||
|
direction: { type: String, required: false },
|
||||||
|
noBodyStyles: { type: Boolean, required: false },
|
||||||
|
handleOnly: { type: Boolean, required: false },
|
||||||
|
preventScrollRestoration: { type: Boolean, required: false },
|
||||||
|
snapPoints: { type: Array, required: false },
|
||||||
|
fadeFromIndex: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits([
|
||||||
|
"drag",
|
||||||
|
"release",
|
||||||
|
"close",
|
||||||
|
"update:open",
|
||||||
|
"update:activeSnapPoint",
|
||||||
|
"animationEnd",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DrawerRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</DrawerRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { useForwardPropsEmits } from "reka-ui";
|
||||||
|
import { DrawerContent, DrawerPortal } from "vaul-vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import DrawerOverlay from "./DrawerOverlay.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
forceMount: { type: Boolean, required: false },
|
||||||
|
disableOutsidePointerEvents: { type: Boolean, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
const emits = defineEmits([
|
||||||
|
"escapeKeyDown",
|
||||||
|
"pointerDownOutside",
|
||||||
|
"focusOutside",
|
||||||
|
"interactOutside",
|
||||||
|
"openAutoFocus",
|
||||||
|
"closeAutoFocus",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
const forwardedProps = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DrawerPortal>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerContent
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||||
|
<slot />
|
||||||
|
</DrawerContent>
|
||||||
|
</DrawerPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { DrawerDescription } from "vaul-vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DrawerDescription
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DrawerDescription>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('mt-auto flex flex-col gap-2 p-4', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('grid gap-1.5 p-4 text-center sm:text-left', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { DrawerOverlay } from "vaul-vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
forceMount: { type: Boolean, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DrawerOverlay
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('fixed inset-0 z-50 bg-black/80', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { DrawerTitle } from "vaul-vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DrawerTitle
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn('text-lg font-semibold leading-none tracking-tight', props.class)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DrawerTitle>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { default as Drawer } from "./Drawer.vue";
|
||||||
|
export { default as DrawerContent } from "./DrawerContent.vue";
|
||||||
|
export { default as DrawerDescription } from "./DrawerDescription.vue";
|
||||||
|
export { default as DrawerFooter } from "./DrawerFooter.vue";
|
||||||
|
export { default as DrawerHeader } from "./DrawerHeader.vue";
|
||||||
|
export { default as DrawerOverlay } from "./DrawerOverlay.vue";
|
||||||
|
export { default as DrawerTitle } from "./DrawerTitle.vue";
|
||||||
|
export { DrawerClose, DrawerPortal, DrawerTrigger } from "vaul-vue";
|
||||||
@@ -26,7 +26,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
:class="cn('z-50 min-w-32 overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md max-h-[var(--reka-dropdown-menu-content-available-height)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
import { router } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for infinite scroll with Inertia v2.
|
||||||
|
*
|
||||||
|
* @param {Function} getProp - () => the current paginator object from Inertia props
|
||||||
|
* @param {string} propName - the prop key name to reload
|
||||||
|
* @param {string} pageParam - query string parameter name for page number
|
||||||
|
* @param {Function} getRouteUrl - () => current URL to reload
|
||||||
|
*/
|
||||||
|
export function useInfiniteList(getProp, propName, pageParam, getRouteUrl) {
|
||||||
|
const items = ref([]);
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const lastPage = ref(1);
|
||||||
|
const isLoadingMore = ref(false);
|
||||||
|
const sentinelRef = ref(null);
|
||||||
|
let observer = null;
|
||||||
|
|
||||||
|
function syncFromProp() {
|
||||||
|
const prop = getProp();
|
||||||
|
if (!prop) return;
|
||||||
|
lastPage.value = prop.last_page ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFromProp() {
|
||||||
|
const prop = getProp();
|
||||||
|
if (!prop?.data) return;
|
||||||
|
// append only new items (avoid duplicates by id)
|
||||||
|
const existingIds = new Set(items.value.map((i) => i.id));
|
||||||
|
const newItems = prop.data.filter((i) => !existingIds.has(i.id));
|
||||||
|
items.value.push(...newItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(initialProp) {
|
||||||
|
items.value = initialProp?.data ?? [];
|
||||||
|
currentPage.value = initialProp?.current_page ?? 1;
|
||||||
|
lastPage.value = initialProp?.last_page ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (isLoadingMore.value) return;
|
||||||
|
if (currentPage.value >= lastPage.value) return;
|
||||||
|
|
||||||
|
const nextPage = currentPage.value + 1;
|
||||||
|
isLoadingMore.value = true;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set(pageParam, nextPage);
|
||||||
|
|
||||||
|
router.reload({
|
||||||
|
url: `${window.location.pathname}?${params.toString()}`,
|
||||||
|
only: [propName],
|
||||||
|
preserveScroll: true,
|
||||||
|
preserveState: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
appendFromProp();
|
||||||
|
currentPage.value = nextPage;
|
||||||
|
isLoadingMore.value = false;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
isLoadingMore.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sentinelRef.value) {
|
||||||
|
observer.observe(sentinelRef.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
currentPage,
|
||||||
|
lastPage,
|
||||||
|
isLoadingMore,
|
||||||
|
sentinelRef,
|
||||||
|
reset,
|
||||||
|
syncFromProp,
|
||||||
|
appendFromProp,
|
||||||
|
loadMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
InboxIcon,
|
InboxIcon,
|
||||||
AtSignIcon,
|
AtSignIcon,
|
||||||
BookUserIcon,
|
BookUserIcon,
|
||||||
MessageSquareIcon,
|
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import Dropdown from "@/Components/Dropdown.vue";
|
import Dropdown from "@/Components/Dropdown.vue";
|
||||||
@@ -211,13 +210,6 @@ const navGroups = computed(() => [
|
|||||||
icon: Settings2Icon,
|
icon: Settings2Icon,
|
||||||
active: ["admin.sms-profiles.index"],
|
active: ["admin.sms-profiles.index"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "admin.packages.index",
|
|
||||||
label: "SMS paketi",
|
|
||||||
route: "admin.packages.index",
|
|
||||||
icon: MessageSquareIcon,
|
|
||||||
active: ["admin.packages.index", "admin.packages.show"],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import { SettingsIcon } from "lucide-vue-next";
|
|||||||
import { ShieldUserIcon } from "lucide-vue-next";
|
import { ShieldUserIcon } from "lucide-vue-next";
|
||||||
import { SmartphoneIcon } from "lucide-vue-next";
|
import { SmartphoneIcon } from "lucide-vue-next";
|
||||||
import { TabletSmartphoneIcon } from "lucide-vue-next";
|
import { TabletSmartphoneIcon } from "lucide-vue-next";
|
||||||
|
import { PhoneCallIcon } from "lucide-vue-next";
|
||||||
|
import { PackageIcon } from "lucide-vue-next";
|
||||||
|
import { Badge } from "@/Components/ui/badge";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: String,
|
title: String,
|
||||||
@@ -157,6 +160,13 @@ const rawMenuGroups = [
|
|||||||
routeName: "segments.index",
|
routeName: "segments.index",
|
||||||
active: ["segments.index"],
|
active: ["segments.index"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "call-laters",
|
||||||
|
icon: PhoneCallIcon,
|
||||||
|
title: "Pokliči kasneje",
|
||||||
|
routeName: "callLaters.index",
|
||||||
|
active: ["callLaters.index"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -212,6 +222,13 @@ const rawMenuGroups = [
|
|||||||
routeName: "settings",
|
routeName: "settings",
|
||||||
active: ["settings", "settings.*"],
|
active: ["settings", "settings.*"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "packages",
|
||||||
|
icon: PackageIcon,
|
||||||
|
title: "Paketno pošiljanje",
|
||||||
|
routeName: "packages.index",
|
||||||
|
active: ["packages.index", "packages.show", "packages.create"],
|
||||||
|
},
|
||||||
// Admin panel (roles & permissions management)
|
// Admin panel (roles & permissions management)
|
||||||
// Only shown if current user has admin role or manage-settings permission.
|
// Only shown if current user has admin role or manage-settings permission.
|
||||||
// We'll filter it out below if not authorized.
|
// We'll filter it out below if not authorized.
|
||||||
@@ -268,6 +285,14 @@ function isActive(patterns) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBadge(item) {
|
||||||
|
if (item.key === "call-laters") {
|
||||||
|
return page.props.callLaterCount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -341,11 +366,18 @@ function isActive(patterns) {
|
|||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<span
|
<span
|
||||||
v-if="!sidebarCollapsed"
|
v-if="!sidebarCollapsed"
|
||||||
class="truncate transition-opacity"
|
class="flex-1 truncate transition-opacity"
|
||||||
:class="{ 'font-medium': isActive(item.active) }"
|
:class="{ 'font-medium': isActive(item.active) }"
|
||||||
>
|
>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</span>
|
</span>
|
||||||
|
<Badge
|
||||||
|
v-if="!sidebarCollapsed && getBadge(item) > 0"
|
||||||
|
variant="destructive"
|
||||||
|
class="ml-auto shrink-0 px-1.5 py-0.5 text-xs font-mono text-amber-50"
|
||||||
|
>
|
||||||
|
{{ getBadge(item) }}
|
||||||
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ const closeSearch = () => (searchOpen.value = false);
|
|||||||
<div class="flex-1 flex flex-col min-w-0">
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<div
|
<div
|
||||||
class="h-16 bg-white border-b border-gray-200 px-4 flex items-center justify-between sticky top-0 z-30 backdrop-blur-sm bg-white/95 shadow-sm"
|
class="h-16 border-b border-gray-200 px-4 flex items-center justify-between sticky top-0 z-30 backdrop-blur-sm bg-white/95 shadow-sm"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Sidebar toggle -->
|
<!-- Sidebar toggle -->
|
||||||
@@ -308,7 +308,10 @@ const closeSearch = () => (searchOpen.value = false);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Page Heading -->
|
<!-- Page Heading -->
|
||||||
<header v-if="$slots.header" class="bg-white border-b border-gray-200 shadow-sm">
|
<header
|
||||||
|
v-if="$slots.header"
|
||||||
|
class="sticky top-16 z-20 bg-white border-b border-gray-200 shadow-sm dark:bg-gray-900 dark:border-gray-700"
|
||||||
|
>
|
||||||
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
|
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
|
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
|
||||||
@@ -319,7 +322,7 @@ const closeSearch = () => (searchOpen.value = false);
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<main class="flex-1 p-4 sm:p-6">
|
<main class="flex-1 lg:p-4">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import "quill/dist/quill.snow.css";
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
template: { type: Object, default: null },
|
template: { type: Object, default: null },
|
||||||
|
actions: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@@ -75,6 +76,9 @@ const form = useForm({
|
|||||||
entity_types: props.template?.entity_types ?? ["client", "contract"],
|
entity_types: props.template?.entity_types ?? ["client", "contract"],
|
||||||
allow_attachments: props.template?.allow_attachments ?? false,
|
allow_attachments: props.template?.allow_attachments ?? false,
|
||||||
active: props.template?.active ?? true,
|
active: props.template?.active ?? true,
|
||||||
|
client: props.template?.client ?? false,
|
||||||
|
action_id: props.template?.action_id ?? null,
|
||||||
|
decision_id: props.template?.decision_id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const preview = ref({ subject: "", html: "", text: "" });
|
const preview = ref({ subject: "", html: "", text: "" });
|
||||||
@@ -732,7 +736,8 @@ const placeholderGroups = computed(() => {
|
|||||||
"contract.id",
|
"contract.id",
|
||||||
"contract.uuid",
|
"contract.uuid",
|
||||||
"contract.reference",
|
"contract.reference",
|
||||||
"contract.amount",
|
"contract.account.balance_amount",
|
||||||
|
"contract.account.initial_amount",
|
||||||
"contract.meta.some_key",
|
"contract.meta.some_key",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -747,6 +752,13 @@ const placeholderGroups = computed(() => {
|
|||||||
]);
|
]);
|
||||||
// Extra is always useful for ad-hoc data
|
// Extra is always useful for ad-hoc data
|
||||||
add("extra", "Extra", ["extra.some_key"]);
|
add("extra", "Extra", ["extra.some_key"]);
|
||||||
|
// Profile signature tokens (resolved from the active mail profile at send time)
|
||||||
|
add("profile", "Profil / Podpis", [
|
||||||
|
"profile.signature.ime",
|
||||||
|
"profile.signature.naziv",
|
||||||
|
"profile.signature.telefon",
|
||||||
|
"profile.signature.email",
|
||||||
|
]);
|
||||||
return groups;
|
return groups;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1028,6 +1040,49 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<Label for="active" class="font-normal cursor-pointer">Aktivno</Label>
|
<Label for="active" class="font-normal cursor-pointer">Aktivno</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="client"
|
||||||
|
:default-value="form.client"
|
||||||
|
@update:model-value="(val) => (form.client = val)"
|
||||||
|
/>
|
||||||
|
<Label for="client" class="font-normal cursor-pointer">Samo za stranke</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity after send: action + decision -->
|
||||||
|
<div>
|
||||||
|
<Label class="mb-2 block">Aktivnost po pošiljanju</Label>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="action_id">Akcija</Label>
|
||||||
|
<Select v-model="form.action_id" @update:model-value="(val) => { form.action_id = val; form.decision_id = null; }">
|
||||||
|
<SelectTrigger id="action_id">
|
||||||
|
<SelectValue placeholder="Brez" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="null">Brez</SelectItem>
|
||||||
|
<SelectItem v-for="a in props.actions" :key="a.id" :value="a.id">{{ a.name }}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="decision_id">Odločitev</Label>
|
||||||
|
<Select v-model="form.decision_id" :disabled="!form.action_id">
|
||||||
|
<SelectTrigger id="decision_id">
|
||||||
|
<SelectValue placeholder="Brez" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="null">Brez</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="d in props.actions?.find((x) => x.id === form.action_id)?.decisions || []"
|
||||||
|
:key="d.id"
|
||||||
|
:value="d.id"
|
||||||
|
>{{ d.name }}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -1223,6 +1278,25 @@ watch(
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Special tokens -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-sm font-medium text-muted-foreground">Posebni žetoni</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
<code class="font-mono">{{ 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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -107,12 +107,6 @@ const cards = [
|
|||||||
route: "admin.sms-logs.index",
|
route: "admin.sms-logs.index",
|
||||||
icon: InboxIcon,
|
icon: InboxIcon,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "SMS paketi",
|
|
||||||
description: "Kreiranje in pošiljanje serijskih SMS paketov",
|
|
||||||
route: "admin.packages.index",
|
|
||||||
icon: MessageSquareIcon,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
PencilIcon,
|
PencilIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
|
Trash2Icon,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -62,6 +63,32 @@ const createOpen = ref(false); // create modal
|
|||||||
const editOpen = ref(false); // edit modal
|
const editOpen = ref(false); // edit modal
|
||||||
const editTarget = ref(null); // profile being edited
|
const editTarget = ref(null); // profile being edited
|
||||||
|
|
||||||
|
// Signature items — array of {key, value} pairs edited in the dialog
|
||||||
|
const signatureItems = ref([{ key: "", value: "" }]);
|
||||||
|
|
||||||
|
function addSignatureItem() {
|
||||||
|
signatureItems.value.push({ key: "", value: "" });
|
||||||
|
}
|
||||||
|
function removeSignatureItem(index) {
|
||||||
|
signatureItems.value.splice(index, 1);
|
||||||
|
if (signatureItems.value.length === 0)
|
||||||
|
signatureItems.value.push({ key: "", value: "" });
|
||||||
|
}
|
||||||
|
function signatureToObject() {
|
||||||
|
const obj = {};
|
||||||
|
signatureItems.value.forEach(({ key, value }) => {
|
||||||
|
const k = (key || "").trim();
|
||||||
|
if (k) obj[k] = value ?? "";
|
||||||
|
});
|
||||||
|
return Object.keys(obj).length ? obj : null;
|
||||||
|
}
|
||||||
|
function signatureFromObject(sig) {
|
||||||
|
const entries = Object.entries(sig || {});
|
||||||
|
return entries.length
|
||||||
|
? entries.map(([key, value]) => ({ key, value }))
|
||||||
|
: [{ key: "", value: "" }];
|
||||||
|
}
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: "",
|
name: "",
|
||||||
host: "",
|
host: "",
|
||||||
@@ -72,10 +99,12 @@ const form = useForm({
|
|||||||
from_address: "",
|
from_address: "",
|
||||||
from_name: "",
|
from_name: "",
|
||||||
priority: 10,
|
priority: 10,
|
||||||
|
auto_mailer: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
signatureItems.value = [{ key: "", value: "" }];
|
||||||
createOpen.value = true;
|
createOpen.value = true;
|
||||||
editTarget.value = null;
|
editTarget.value = null;
|
||||||
}
|
}
|
||||||
@@ -92,7 +121,9 @@ function openEdit(p) {
|
|||||||
form.from_address = p.from_address || "";
|
form.from_address = p.from_address || "";
|
||||||
form.from_name = p.from_name || "";
|
form.from_name = p.from_name || "";
|
||||||
form.priority = p.priority ?? 10;
|
form.priority = p.priority ?? 10;
|
||||||
|
form.auto_mailer = p.auto_mailer ?? false;
|
||||||
editTarget.value = p;
|
editTarget.value = p;
|
||||||
|
signatureItems.value = signatureFromObject(p.signature);
|
||||||
editOpen.value = true;
|
editOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +133,9 @@ function closeCreate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function submitCreate() {
|
function submitCreate() {
|
||||||
form.post(route("admin.mail-profiles.store"), {
|
form
|
||||||
|
.transform((data) => ({ ...data, signature: signatureToObject() }))
|
||||||
|
.post(route("admin.mail-profiles.store"), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
createOpen.value = false;
|
createOpen.value = false;
|
||||||
@@ -128,6 +161,8 @@ function submitEdit() {
|
|||||||
from_address: form.from_address,
|
from_address: form.from_address,
|
||||||
from_name: form.from_name || null,
|
from_name: form.from_name || null,
|
||||||
priority: form.priority,
|
priority: form.priority,
|
||||||
|
auto_mailer: form.auto_mailer,
|
||||||
|
signature: signatureToObject(),
|
||||||
};
|
};
|
||||||
if (form.password && form.password.trim() !== "") {
|
if (form.password && form.password.trim() !== "") {
|
||||||
payload.password = form.password.trim();
|
payload.password = form.password.trim();
|
||||||
@@ -149,6 +184,12 @@ function toggleActive(p) {
|
|||||||
.then(() => window.location.reload());
|
.then(() => window.location.reload());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAutoMailer(p) {
|
||||||
|
window.axios
|
||||||
|
.post(route("admin.mail-profiles.toggle-auto-mailer", p.id))
|
||||||
|
.then(() => window.location.reload());
|
||||||
|
}
|
||||||
|
|
||||||
function testConnection(p) {
|
function testConnection(p) {
|
||||||
window.axios
|
window.axios
|
||||||
.post(route("admin.mail-profiles.test", p.id))
|
.post(route("admin.mail-profiles.test", p.id))
|
||||||
@@ -206,6 +247,7 @@ const statusClass = (p) => {
|
|||||||
<TableHead class="text-center">Port</TableHead>
|
<TableHead class="text-center">Port</TableHead>
|
||||||
<TableHead class="text-center">Enc</TableHead>
|
<TableHead class="text-center">Enc</TableHead>
|
||||||
<TableHead class="text-center">Aktivno</TableHead>
|
<TableHead class="text-center">Aktivno</TableHead>
|
||||||
|
<TableHead class="text-center">Auto-mailer</TableHead>
|
||||||
<TableHead class="text-center">Status</TableHead>
|
<TableHead class="text-center">Status</TableHead>
|
||||||
<TableHead>Zadnji uspeh</TableHead>
|
<TableHead>Zadnji uspeh</TableHead>
|
||||||
<TableHead>Napaka</TableHead>
|
<TableHead>Napaka</TableHead>
|
||||||
@@ -229,6 +271,12 @@ const statusClass = (p) => {
|
|||||||
@update:model-value="() => toggleActive(p)"
|
@update:model-value="() => toggleActive(p)"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell class="text-center">
|
||||||
|
<Switch
|
||||||
|
:default-value="p.auto_mailer"
|
||||||
|
@update:model-value="() => toggleAutoMailer(p)"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell class="text-center">
|
<TableCell class="text-center">
|
||||||
<Badge
|
<Badge
|
||||||
v-if="p.test_status === 'success'"
|
v-if="p.test_status === 'success'"
|
||||||
@@ -350,6 +398,51 @@ const statusClass = (p) => {
|
|||||||
<Label for="create-priority">Prioriteta</Label>
|
<Label for="create-priority">Prioriteta</Label>
|
||||||
<Input id="create-priority" v-model.number="form.priority" type="number" />
|
<Input id="create-priority" v-model.number="form.priority" type="number" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id="create-auto-mailer"
|
||||||
|
:model-value="form.auto_mailer"
|
||||||
|
@update:model-value="(val) => (form.auto_mailer = val)"
|
||||||
|
/>
|
||||||
|
<Label for="create-auto-mailer">Auto-mailer</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>Podpis (signature)</Label>
|
||||||
|
<Button type="button" size="sm" variant="outline" @click="addSignatureItem">
|
||||||
|
<PlusIcon class="h-3 w-3 mr-1" />
|
||||||
|
Dodaj vrstico
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Vrednosti so dostopne v predlogah kot
|
||||||
|
<code class="font-mono" v-pre>{{ profile.signature.ključ }}</code
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(item, i) in signatureItems"
|
||||||
|
:key="i"
|
||||||
|
class="flex gap-2 items-start"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="item.key"
|
||||||
|
placeholder="Ključ (npr. ime)"
|
||||||
|
class="w-36 shrink-0 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Input v-model="item.value" placeholder="Vrednost" class="flex-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="removeSignatureItem(i)"
|
||||||
|
>
|
||||||
|
<Trash2Icon class="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -418,6 +511,51 @@ const statusClass = (p) => {
|
|||||||
<Label for="edit-priority">Prioriteta</Label>
|
<Label for="edit-priority">Prioriteta</Label>
|
||||||
<Input id="edit-priority" v-model.number="form.priority" type="number" />
|
<Input id="edit-priority" v-model.number="form.priority" type="number" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id="edit-auto-mailer"
|
||||||
|
:model-value="form.auto_mailer"
|
||||||
|
@update:model-value="(val) => (form.auto_mailer = val)"
|
||||||
|
/>
|
||||||
|
<Label for="edit-auto-mailer">Auto-mailer</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>Podpis (signature)</Label>
|
||||||
|
<Button type="button" size="sm" variant="outline" @click="addSignatureItem">
|
||||||
|
<PlusIcon class="h-3 w-3 mr-1" />
|
||||||
|
Dodaj vrstico
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Vrednosti so dostopne v predlogah kot
|
||||||
|
<code class="font-mono" v-pre>{{ profile.signature.ključ }}</code
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(item, i) in signatureItems"
|
||||||
|
:key="i"
|
||||||
|
class="flex gap-2 items-start"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="item.key"
|
||||||
|
placeholder="Ključ (npr. ime)"
|
||||||
|
class="w-36 shrink-0 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Input v-model="item.value" placeholder="Vrednost" class="flex-1" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="removeSignatureItem(i)"
|
||||||
|
>
|
||||||
|
<Trash2Icon class="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
Pusti geslo prazno, če želiš obdržati obstoječe.
|
Pusti geslo prazno, če želiš obdržati obstoječe.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user