1 Commits

Author SHA1 Message Date
sipo 9f8e0c42ec dev changes 2026-05-10 14:32:17 +02:00
133 changed files with 8264 additions and 15767 deletions
+27
View File
@@ -0,0 +1,27 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
+9 -1
View File
@@ -38,4 +38,12 @@ DEDUPLICATION_*.md
docker-compose.local.yaml
docker-compose.override.yaml
.env.local
.env.docker
.env.docker
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
@@ -1,132 +0,0 @@
<?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,7 +6,6 @@
use App\Models\EmailLog;
use App\Models\EmailTemplate;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -70,15 +69,4 @@ public function show(EmailLog $emailLog): Response
'log' => $emailLog,
]);
}
public function body(EmailLog $emailLog): JsonResponse
{
$this->authorize('viewAny', EmailTemplate::class);
$emailLog->load('body');
return response()->json([
'html' => $emailLog->body?->body_html ?? '',
]);
}
}
@@ -13,7 +13,6 @@
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Models\EmailTemplate;
use App\Models\MailProfile;
use App\Services\EmailTemplateRenderer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
@@ -56,14 +55,8 @@ public function create(): Response
{
$this->authorize('create', EmailTemplate::class);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/EmailTemplates/Edit', [
'template' => null,
'actions' => $actions,
]);
}
@@ -100,7 +93,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
// Context resolution (shared logic with renderFinalHtml)
$ctx = [];
if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
if ($activity) {
$ctx['activity'] = $activity;
// Derive base entities from activity when not explicitly provided
@@ -117,7 +110,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
}
}
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
if ($contract) {
$ctx['contract'] = $contract;
if ($contract->clientCase) {
@@ -147,7 +140,6 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
}
}
$ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
$rendered = $renderer->render([
'subject' => $subject,
@@ -169,14 +161,8 @@ public function edit(EmailTemplate $emailTemplate): Response
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
}]);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/EmailTemplates/Edit', [
'template' => $emailTemplate,
'actions' => $actions,
]);
}
@@ -195,7 +181,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
// Context resolution
$ctx = [];
if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
if ($activity) {
$ctx['activity'] = $activity;
if ($activity->contract && ! isset($ctx['contract'])) {
@@ -211,7 +197,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
}
}
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
if ($contract) {
$ctx['contract'] = $contract;
if ($contract->clientCase) {
@@ -241,7 +227,6 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
}
}
$ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
// Render preview values; we store a minimal snapshot on the log
$rendered = $renderer->render([
@@ -308,7 +293,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
// Context resolution (same as sendTest)
$ctx = [];
if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
if ($activity) {
$ctx['activity'] = $activity;
if ($activity->contract && ! isset($ctx['contract'])) {
@@ -324,7 +309,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
}
}
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
if ($contract) {
$ctx['contract'] = $contract;
if ($contract->clientCase) {
@@ -354,7 +339,6 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
}
}
$ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
$rendered = $renderer->render([
'subject' => $subject,
@@ -26,7 +26,7 @@ public function index(): Response
->orderBy('priority')
->orderBy('id')
->get([
'id', 'name', 'active', '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',
'id', 'name', 'active', 'host', 'port', 'encryption', 'from_address', 'priority', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
]);
return Inertia::render('Admin/MailProfiles/Index', [
@@ -76,15 +76,6 @@ public function toggle(Request $request, MailProfile $mailProfile)
return back()->with('success', 'Status updated');
}
public function toggleAutoMailer(Request $request, MailProfile $mailProfile)
{
$this->authorize('update', $mailProfile);
$mailProfile->auto_mailer = ! $mailProfile->auto_mailer;
$mailProfile->save();
return back()->with('success', 'Auto-mailer updated');
}
public function test(Request $request, MailProfile $mailProfile)
{
$this->authorize('test', $mailProfile);
@@ -3,18 +3,16 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEmailPackageFromContractsRequest;
use App\Http\Requests\StorePackageFromContractsRequest;
use App\Http\Requests\StorePackageRequest;
use App\Jobs\PackageItemEmailJob;
use App\Jobs\PackageItemSmsJob;
use App\Models\Contract;
use App\Models\Package;
use App\Models\PackageItem;
use App\Models\SmsTemplate;
use App\Services\Contact\EmailSelector;
use App\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
@@ -24,40 +22,20 @@
class PackageController extends Controller
{
public function landing(): Response
{
return Inertia::render('Packages/Index');
}
public function smsIndex(Request $request): Response
public function index(Request $request): Response
{
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query()
->where('type', Package::TYPE_SMS)
->latest('id')
->paginate($perPage);
return Inertia::render('Packages/Sms/Index', [
return Inertia::render('Admin/Packages/Index', [
'packages' => $packages,
]);
}
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
public function create(Request $request): Response
{
// Minimal lookups for create form (active only)
$profiles = \App\Models\SmsProfile::query()
@@ -92,7 +70,7 @@ public function smsCreate(Request $request): Response
})
->values();
return Inertia::render('Packages/Sms/Create', [
return Inertia::render('Admin/Packages/Create', [
'profiles' => $profiles,
'senders' => $senders,
'templates' => $templates,
@@ -101,53 +79,7 @@ public function smsCreate(Request $request): 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
public function show(Package $package, SmsService $sms): Response
{
$items = $package->items()->latest('id')->paginate(25);
@@ -281,23 +213,13 @@ public function smsShow(Package $package, SmsService $sms): Response
}
}
return Inertia::render('Packages/Sms/Show', [
return Inertia::render('Admin/Packages/Show', [
'package' => $package,
'items' => $items,
'preview' => $preview,
]);
}
public function emailShow(Package $package): Response
{
$items = $package->items()->latest('id')->paginate(25);
return Inertia::render('Packages/Mail/Show', [
'package' => $package,
'items' => $items,
]);
}
public function store(StorePackageRequest $request): RedirectResponse
{
$data = $request->validated();
@@ -339,11 +261,7 @@ public function dispatch(Package $package): RedirectResponse
return back()->with('error', 'Package not in a dispatchable state.');
}
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) use ($package) {
if ($package->type === Package::TYPE_EMAIL) {
return new PackageItemEmailJob($item->id);
}
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) {
return new PackageItemSmsJob($item->id);
})->all();
@@ -369,7 +287,7 @@ public function dispatch(Package $package): RedirectResponse
$package->save();
}
})
->onQueue($package->type === Package::TYPE_EMAIL ? 'email' : 'sms')
->onQueue('sms')
->dispatch();
return back()->with('success', 'Package dispatched');
@@ -528,7 +446,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
'number' => $phone->nu,
'validated' => $phone->validated,
'type' => $phone->phone_type?->value,
'description' => $phone->description,
] : null,
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
];
@@ -625,213 +542,6 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
return back()->with('success', 'Package created from contracts');
}
/**
* List contracts with selected email per person (for email packages).
*/
public function contractsForEmail(Request $request, EmailSelector $selector): \Illuminate\Http\JsonResponse
{
$request->validate([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_verified' => ['nullable', 'boolean'],
'only_with_email' => ['nullable', 'boolean'],
'start_date_from' => ['nullable', 'date'],
'start_date_to' => ['nullable', 'date'],
'promise_date_from' => ['nullable', 'date'],
'promise_date_to' => ['nullable', 'date'],
]);
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
$query = Contract::query()
->with([
'clientCase.person.emails',
'clientCase.client.person',
'account',
'segments:id,name',
])
->select('contracts.*')
->latest('contracts.id');
if ($segmentId) {
$query->join('contract_segment', function ($j) use ($segmentId) {
$j->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.segment_id', '=', $segmentId)
->where('contract_segment.active', true);
});
} else {
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
->from('contract_segment')
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
->where('contract_segment.active', true)
->where('segments.exclude', false)
->whereColumn('contract_segment.contract_id', 'contracts.id')
);
}
if ($q = trim((string) $request->input('q'))) {
$query->where('contracts.reference', 'ILIKE', "%{$q}%");
}
if ($clientId = $request->integer('client_id')) {
$query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
->where('client_cases.client_id', $clientId);
}
if ($startDateFrom = $request->input('start_date_from')) {
$query->where('contracts.start_date', '>=', $startDateFrom);
}
if ($startDateTo = $request->input('start_date_to')) {
$query->where('contracts.start_date', '<=', $startDateTo);
}
$promiseDateFrom = $request->input('promise_date_from');
$promiseDateTo = $request->input('promise_date_to');
if ($promiseDateFrom || $promiseDateTo) {
$query->whereHas('account', function ($q) use ($promiseDateFrom, $promiseDateTo) {
if ($promiseDateFrom) {
$q->where('promise_date', '>=', $promiseDateFrom);
}
if ($promiseDateTo) {
$q->where('promise_date', '<=', $promiseDateTo);
}
});
}
if ($request->boolean('only_verified')) {
$query->whereHas('clientCase.person.emails', function ($q) {
$q->where('is_active', true)->whereNotNull('verified_at');
});
}
if ($request->boolean('only_with_email')) {
$query->whereHas('clientCase.person.emails', function ($q) {
$q->where('is_active', true);
});
}
$contracts = $query->limit(500)->get();
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['email' => null, 'reason' => 'no_person'];
$email = $selected['email'];
$clientPerson = $contract->clientCase?->client?->person;
$segment = collect($contract->segments)->last();
return [
'id' => $contract->id,
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'start_date' => $contract->start_date,
'promise_date' => $contract->account?->promise_date,
'case' => [
'id' => $contract->clientCase?->id,
'uuid' => $contract->clientCase?->uuid,
],
'person' => [
'id' => $person?->id,
'uuid' => $person?->uuid,
'full_name' => $person?->full_name,
],
'segment' => $segment,
'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id,
'uuid' => $contract->clientCase?->client?->uuid,
'name' => $clientPerson->full_name,
] : null,
'selected_email' => $email ? [
'id' => $email->id,
'value' => $email->value,
'is_primary' => $email->is_primary,
'verified' => $email->verified_at !== null,
'label' => $email->label,
] : null,
'no_email_reason' => $email ? null : ($selected['reason'] ?? 'unknown'),
];
});
return response()->json(['data' => $data]);
}
/**
* Create an email package from a list of contracts by selecting recipient emails.
*/
public function storeEmailFromContracts(StoreEmailPackageFromContractsRequest $request, EmailSelector $selector): RedirectResponse
{
$data = $request->validated();
$contracts = Contract::query()
->with(['clientCase.person', 'account.type'])
->whereIn('id', $data['contract_ids'])
->get();
$items = [];
$skipped = 0;
foreach ($contracts as $contract) {
$person = $contract->clientCase?->person;
if (! $person) {
$skipped++;
continue;
}
$selected = $selector->selectForPerson($person);
/** @var ?\App\Models\Email $email */
$email = $selected['email'];
if (! $email) {
$skipped++;
continue;
}
$items[] = [
'email' => $email->value,
'email_id' => $email->id,
'payload' => $data['payload'] ?? [],
'contract_id' => $contract->id,
'account_id' => $contract->account?->id,
];
}
if (empty($items)) {
return back()->with('error', 'No recipients found for selected contracts.');
}
$package = Package::query()->create([
'uuid' => (string) Str::uuid(),
'type' => Package::TYPE_EMAIL,
'status' => Package::STATUS_DRAFT,
'name' => $data['name'] ?? null,
'description' => $data['description'] ?? null,
'meta' => array_merge($data['meta'] ?? [], [
'source' => 'contracts',
'skipped' => $skipped,
]),
'created_by' => optional($request->user())->id,
]);
$packageItems = collect($items)->map(function (array $row) {
return new PackageItem([
'status' => 'queued',
'target_json' => [
'email' => $row['email'],
'email_id' => $row['email_id'],
'contract_id' => $row['contract_id'] ?? null,
'account_id' => $row['account_id'] ?? null,
],
'payload_json' => $row['payload'] ?? [],
]);
});
$package->items()->saveMany($packageItems);
$package->total_items = $packageItems->count();
$package->save();
return back()->with('success', 'Email package created from contracts');
}
/**
* Flatten nested meta structure into dot-notation key-value pairs.
* Extracts 'value' from objects with {title, value, type} structure.
@@ -20,7 +20,7 @@ public function index(Request $request): Response
{
Gate::authorize('manage-settings');
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active', 'login_redirect']);
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active']);
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
@@ -73,17 +73,4 @@ public function toggleActive(User $user): RedirectResponse
return back()->with('success', "Uporabnik {$status}");
}
public function updateSettings(Request $request, User $user): RedirectResponse
{
Gate::authorize('manage-settings');
$validated = $request->validate([
'login_redirect' => ['nullable', 'string', 'max:255'],
]);
$user->update($validated);
return back()->with('success', 'Nastavitve shranjene');
}
}
@@ -1,53 +0,0 @@
<?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.');
}
}
+45 -247
View File
@@ -71,8 +71,10 @@ public function index(ClientCase $clientCase, Request $request)
$que->whereDate('client_cases.created_at', '<=', $to);
})
->groupBy('client_cases.id')
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
->addSelect([
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->with(['person.client', 'client.person'])
->orderByDesc('client_cases.created_at');
@@ -221,11 +223,7 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
return back()->with('warning', __('contracts.edit_not_allowed_archived'));
}
$balanceChanged = false;
$oldBalance = null;
$newBalance = null;
\DB::transaction(function () use ($request, $contract, &$balanceChanged, &$oldBalance, &$newBalance) {
\DB::transaction(function () use ($request, $contract) {
$contract->update([
'reference' => $request->input('reference'),
'type_id' => $request->input('type_id'),
@@ -256,7 +254,6 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
$accountData['type_id'] = $request->input('account_type_id');
}
if ($currentAccount) {
$oldBalance = (float) $currentAccount->balance_amount;
$currentAccount->update($accountData);
if (array_key_exists('balance_amount', $accountData)) {
$currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save();
@@ -267,10 +264,6 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]);
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
}
$newBalance = $freshBal;
if ($oldBalance !== $freshBal) {
$balanceChanged = true;
}
} else {
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
}
@@ -283,27 +276,6 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
});
// Fire activity if balance changed and settings require it
if ($balanceChanged) {
$contractSetting = \App\Models\ContractSetting::query()->first();
if ($contractSetting && $contractSetting->create_activity_on_balance_change) {
$note = str_replace(
['{old_balance}', '{new_balance}', '{currency}'],
[number_format($oldBalance, 2, '.', ''), number_format($newBalance, 2, '.', ''), 'EUR'],
$contractSetting->activity_note_template ?? ''
);
\App\Models\Activity::query()->create([
'due_date' => null,
'amount' => $newBalance,
'note' => $note,
'action_id' => $contractSetting->default_action_id,
'decision_id' => $contractSetting->default_decision_id,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
]);
}
}
// Preserve segment filter if present
$segment = request('segment');
@@ -334,7 +306,6 @@ public function storeActivity(ClientCase $clientCase, Request $request)
try {
$attributes = $request->validate([
'due_date' => 'nullable|date',
'call_back_at' => 'nullable|date_format:Y-m-d H:i:s|after_or_equal:now',
'amount' => 'nullable|decimal:0,4',
'note' => 'nullable|string',
'action_id' => 'exists:\App\Models\Action,id',
@@ -355,14 +326,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
// Determine which contracts to process
$contractIds = [];
if ($createForAll && ! empty($contractUuids)) {
if ($createForAll && !empty($contractUuids)) {
// Get all contract IDs from the provided UUIDs
$contracts = Contract::withTrashed()
->whereIn('uuid', $contractUuids)
->where('client_case_id', $clientCase->id)
->get();
$contractIds = $contracts->pluck('id')->toArray();
} elseif (! empty($contractUuids) && isset($contractUuids[0])) {
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
// Single contract mode
$contract = Contract::withTrashed()
->where('uuid', $contractUuids[0])
@@ -371,7 +342,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
if ($contract) {
$contractIds = [$contract->id];
}
} elseif (! empty($attributes['contract_uuid'])) {
} elseif (!empty($attributes['contract_uuid'])) {
// Legacy single contract_uuid support
$contract = Contract::withTrashed()
->where('uuid', $attributes['contract_uuid'])
@@ -389,7 +360,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
$createdActivities = [];
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
// Disable auto mail if creating activities for multiple contracts
if ($sendFlag && count($contractIds) > 1) {
$sendFlag = false;
@@ -400,7 +371,6 @@ public function storeActivity(ClientCase $clientCase, Request $request)
// Create activity
$row = $clientCase->activities()->create([
'due_date' => $attributes['due_date'] ?? null,
'call_back_at' => $attributes['call_back_at'] ?? null,
'amount' => $attributes['amount'] ?? null,
'note' => $attributes['note'] ?? null,
'action_id' => $attributes['action_id'],
@@ -447,29 +417,29 @@ public function storeActivity(ClientCase $clientCase, Request $request)
->whereIn('id', $attachmentIds)
->pluck('id');
$validAttachmentIds = Document::query()
->where('documentable_type', Contract::class)
->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds)
->pluck('id');
}
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
'attachment_ids' => $validAttachmentIds->all(),
]);
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
// If template requires contract and user attempted to send, surface a validation message
logger()->warning('Email not queued: required contract is missing for the selected template.');
}
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
}
} catch (\Throwable $e) {
// Do not fail activity creation due to mailing issues
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
->where('documentable_type', Contract::class)
->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds)
->pluck('id');
}
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
'attachment_ids' => $validAttachmentIds->all(),
]);
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
// If template requires contract and user attempted to send, surface a validation message
logger()->warning('Email not queued: required contract is missing for the selected template.');
}
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
}
} catch (\Throwable $e) {
// Do not fail activity creation due to mailing issues
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
}
}
$activityCount = count($createdActivities);
$successMessage = $activityCount > 1
$successMessage = $activityCount > 1
? "Successfully created {$activityCount} activities!"
: 'Successfully created activity!';
@@ -632,9 +602,9 @@ public function storeDocument(ClientCase $clientCase, Request $request)
$contract = null;
if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
/*if ($contract && ! $contract->active) {
if ($contract && ! $contract->active) {
return back()->with('warning', __('contracts.document_not_allowed_archived'));
}*/
}
}
$directory = $contract
? ('contracts/'.$contract->uuid.'/documents')
@@ -855,8 +825,9 @@ public function show(ClientCase $clientCase)
}
// Get contracts using service
$contracts = $this->caseDataService->getContracts($case, $segmentId);
$contractIds = collect($contracts)->pluck('id')->all();
$contractsPerPage = request()->integer('contracts_per_page', 10);
$contracts = $this->caseDataService->getContracts($case, $segmentId, $contractsPerPage);
$contractIds = collect($contracts->items())->pluck('id')->all();
// Get activities using service
$activitiesPerPage = request()->integer('activities_per_page', 15);
@@ -897,14 +868,11 @@ public function show(ClientCase $clientCase)
'decisions.emailTemplate' => function ($q) {
$q->select('id', 'name', 'entity_types', 'allow_attachments');
},
'decisions.events' => function ($q) {
$q->select('events.id', 'events.key', 'events.name');
},
])
->get(['id', 'name', 'color_tag', 'segment_id']),
'types' => $types,
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
'all_segments' => Segment::query()->where('active', true)->get(['id', 'name']),
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
'current_segment' => $currentSegment,
'sms_profiles' => \App\Models\SmsProfile::query()
->select(['id', 'name', 'default_sender_id'])
@@ -913,27 +881,14 @@ public function show(ClientCase $clientCase)
->get(),
'sms_senders' => \App\Models\SmsSender::query()
->select(['id', 'profile_id'])
->selectRaw('sname as name')
->selectRaw('phone_number as phone')
->addSelect(\DB::raw('sname as name'))
->addSelect(\DB::raw('phone_number as phone'))
->orderBy('sname')
->get(),
'sms_templates' => \App\Models\SmsTemplate::query()
->select(['id', 'name', 'content', 'allow_custom_body'])
->orderBy('name')
->get(),
'email_templates' => \App\Models\EmailTemplate::query()
->select(['id', 'name', 'subject_template', 'text_template', 'action_id', 'decision_id'])
->where('active', true)
->where('client', false)
->orderBy('name')
->get(),
'mail_profiles' => \App\Models\MailProfile::query()
->select(['id', 'name'])
->where('active', true)
->orderBy('priority')
->orderBy('name')
->get(),
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
]);
}
@@ -1147,7 +1102,6 @@ public function archiveBatch(Request $request)
if (! $setting) {
\Log::warning('No archive settings found for batch archive');
return back()->with('flash', [
'error' => 'No archive settings found',
]);
@@ -1161,14 +1115,13 @@ public function archiveBatch(Request $request)
foreach ($validated['contracts'] as $contractUuid) {
try {
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
// Skip if contract is already archived (active = 0)
if (! $contract->active) {
if (!$contract->active) {
$skippedCount++;
continue;
}
$clientCase = $contract->clientCase;
$context = [
@@ -1255,8 +1208,8 @@ public function archiveBatch(Request $request)
if ($skippedCount > 0) {
$message .= ", skipped $skippedCount already archived";
}
$message .= ', '.count($errors).' failed';
$message .= ", " . count($errors) . " failed";
return back()->with('flash', [
'error' => $message,
'details' => $errors,
@@ -1266,7 +1219,7 @@ public function archiveBatch(Request $request)
$message = $reactivate
? "Successfully reactivated $successCount contracts"
: "Successfully archived $successCount contracts";
if ($skippedCount > 0) {
$message .= " ($skippedCount already archived)";
}
@@ -1392,10 +1345,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
if (! empty($validated['sender_id'])) {
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
if (! $sender) {
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
}
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
}
}
if (! $profile) {
@@ -1438,7 +1391,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
}
// Create an activity before sending
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
$activityData = [
'note' => $activityNote,
'user_id' => optional($request->user())->id,
@@ -1587,161 +1540,6 @@ public function previewSms(ClientCase $clientCase, Request $request, SmsService
* Extracts 'value' from objects with {title, value, type} structure.
* Also creates direct access aliases for nested fields (skipping numeric keys).
*/
/**
* Render an email template preview with context from the client case.
*/
public function previewEmailForEmail(ClientCase $clientCase, Request $request, int $email_id): \Illuminate\Http\JsonResponse
{
$validated = $request->validate([
'template_id' => ['required', 'integer', 'exists:email_templates,id'],
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
'body_text' => ['sometimes', 'nullable', 'string', 'max:10000'],
]);
$email = \App\Models\Email::query()
->where('id', $email_id)
->where('person_id', $clientCase->person_id)
->firstOrFail();
$template = \App\Models\EmailTemplate::findOrFail((int) $validated['template_id']);
$contract = null;
if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()
->where('uuid', $validated['contract_uuid'])
->first();
}
$ctx = $this->buildCaseEmailContext($clientCase, $contract);
$ctx['body_text'] = (string) ($validated['body_text'] ?? '');
$renderer = app(\App\Services\EmailTemplateRenderer::class);
$rendered = $renderer->render([
'subject' => (string) $template->subject_template,
'html' => (string) $template->html_template,
'text' => (string) $template->text_template,
], $ctx);
return response()->json([
'subject' => $rendered['subject'] ?? '',
'html' => (string) ($rendered['html'] ?? ''),
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $template->html_template),
]);
}
/**
* Send a (possibly templated) email to a person email address belonging to this case.
*/
public function sendEmailToEmail(ClientCase $clientCase, Request $request, int $email_id)
{
$validated = $request->validate([
'subject' => ['required', 'string', 'max:255'],
'html_body' => ['nullable', 'string'],
'body_text' => ['nullable', 'string', 'max:10000'],
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:email_templates,id'],
'mail_profile_id' => ['sometimes', 'nullable', 'integer', 'exists:mail_profiles,id'],
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
]);
// Ensure the email belongs to the person of this case
$email = \App\Models\Email::query()
->where('id', $email_id)
->where('person_id', $clientCase->person_id)
->firstOrFail();
$to = (string) $email->value;
/** @var \App\Models\MailProfile|null $mailProfile */
$mailProfile = ! empty($validated['mail_profile_id'])
? \App\Models\MailProfile::query()->where('id', $validated['mail_profile_id'])->where('active', true)->first()
: \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
if (! $mailProfile) {
return back()->with('error', 'Ni aktivnega e-poštnega profila.');
}
$contract = null;
if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
}
$htmlBody = (string) ($validated['html_body'] ?? '');
$bodyText = (string) ($validated['body_text'] ?? '');
// Apply {{body_text}} substitution if the html body contains the placeholder
if ($bodyText !== '' && preg_match('/{{\s*body_text\s*}}/', $htmlBody)) {
$renderer = app(\App\Services\EmailTemplateRenderer::class);
$htmlBody = $renderer->applyBodyText($htmlBody, $bodyText, html: true) ?? $htmlBody;
}
$subject = (string) $validated['subject'];
$log = new \App\Models\EmailLog;
$log->fill([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'template_id' => $validated['template_id'] ?? null,
'mail_profile_id' => $mailProfile->id,
'to_email' => $to,
'to_recipients' => [$to],
'subject' => $subject,
'body_html_hash' => $htmlBody !== '' ? hash('sha256', $htmlBody) : null,
'body_text_preview' => null,
'embed_mode' => 'base64',
'status' => \App\Models\EmailLogStatus::Queued,
'queued_at' => now(),
'client_id' => $clientCase->client_id,
'client_case_id' => $clientCase->id,
'contract_id' => $contract?->id,
'ip' => $request->ip(),
]);
$log->save();
$log->body()->create([
'body_html' => $htmlBody,
'body_text' => $bodyText,
'inline_css' => false,
]);
dispatch(new \App\Jobs\SendEmailTemplateJob($log->id));
// Create activity if template has action/decision
if (! empty($validated['template_id'])) {
$template = \App\Models\EmailTemplate::find((int) $validated['template_id']);
if ($template && ($template->action_id || $template->decision_id)) {
$activity = $clientCase->activities()->create(array_filter([
'contract_id' => $contract?->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => 'Poslano: '.$to.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
'user_id' => optional($request->user())->id,
], fn ($v) => ! is_null($v)));
$activity->emailLogs()->attach($log->id);
}
}
return back()->with('success', "E-pošta poslana na {$to}.");
}
/**
* Build a template rendering context from the given client case and optional contract.
*/
private function buildCaseEmailContext(ClientCase $clientCase, ?\App\Models\Contract $contract = null): array
{
$clientCase->loadMissing('client.person');
$ctx = [
'client_case' => $clientCase,
'client' => $clientCase->client,
'person' => optional($clientCase->client)->person,
'mail_profile' => \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(),
];
if ($contract) {
$contract->loadMissing(['clientCase.client.person', 'account.type']);
$ctx['contract'] = $contract;
}
return $ctx;
}
private function flattenMeta(array $meta, string $prefix = ''): array
{
$result = [];
+13 -9
View File
@@ -27,7 +27,7 @@ public function index(Client $client, Request $request)
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id');
})
// ->where('clients.active', 1)
//->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) {
@@ -40,8 +40,12 @@ public function index(Client $client, Request $request)
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('clients.id')
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count')
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
->addSelect([
// Number of client cases for this client that have at least one active contract
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
// Sum of account balances for active contracts
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->with('person')
->orderByDesc('clients.created_at');
@@ -67,7 +71,6 @@ public function show(Client $client, Request $request)
return Inertia::render('Client/Show', [
'client' => $data,
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
'client_cases' => $data->clientCases()
->select('client_cases.*')
->when($request->input('search'), function ($que, $search) {
@@ -85,8 +88,10 @@ public function show(Client $client, Request $request)
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('client_cases.id')
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
->addSelect([
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->with(['person', 'client.person'])
->where('client_cases.active', 1)
->orderByDesc('client_cases.created_at')
@@ -157,7 +162,6 @@ public function contracts(Client $client, Request $request)
return Inertia::render('Client/Contracts', [
'client' => $data,
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
'contracts' => $contractsQuery
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
->withQueryString(),
@@ -171,7 +175,7 @@ public function exportContracts(ExportClientContractsRequest $request, Client $c
{
$data = $request->validated();
$columns = array_values(array_unique($data['columns']));
$from = $data['from'] ?? null;
$to = $data['to'] ?? null;
$search = $data['search'] ?? null;
@@ -232,7 +236,7 @@ private function buildExportFilename(Client $client): string
{
$datePrefix = now()->format('dmy');
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
}
@@ -1,56 +0,0 @@
<?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.');
}
}
+11 -16
View File
@@ -14,6 +14,7 @@
use App\Services\Sms\SmsService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
@@ -46,9 +47,9 @@ public function __invoke(SmsService $sms): Response
return Account::whereHas('contract', function ($q) {
$q->whereNull('deleted_at');
})
->whereNotNull('promise_date')
->whereDate('promise_date', '>=', $today)
->count();
->whereNotNull('promise_date')
->whereDate('promise_date', '>=', $today)
->count();
});
// Activities (limit 10) - cached
@@ -79,14 +80,14 @@ public function __invoke(SmsService $sms): Response
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
->selectRaw("DATE(COALESCE(assigned_at, created_at) AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
// Completed field jobs last 7 days
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
->whereBetween('completed_at', [$start, $end])
->selectRaw("DATE(completed_at AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
@@ -100,13 +101,13 @@ public function __invoke(SmsService $sms): Response
// Field jobs assigned today - cached
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
return FieldJob::query()
->whereRaw('DATE(COALESCE(assigned_at, created_at)) = ?', [$today->toDateString()])
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
->with(['contract' => function ($q) {
$q->select('id', 'uuid', 'reference', 'client_case_id')
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
}])
->orderByRaw('COALESCE(assigned_at, created_at) DESC')
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
->limit(15)
->get()
->map(function ($fj) {
@@ -119,26 +120,20 @@ public function __invoke(SmsService $sms): Response
}
}
if (! $contract) {
return null;
}
return [
'id' => $fj->id,
'priority' => $fj->priority,
'assigned_at' => $fj->assigned_at?->toIso8601String(),
'created_at' => $fj->created_at?->toIso8601String(),
'contract' => [
'contract' => $contract ? [
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'client_case_uuid' => optional($contract->clientCase)->uuid,
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
'segment_id' => $segmentId,
],
] : null,
];
})
->filter()
->values();
});
});
// System health for timestamp
@@ -64,7 +64,6 @@ public function index(Request $request)
'current_page' => $paginator->currentPage(),
'from' => $paginator->firstItem(),
'last_page' => $paginator->lastPage(),
'links' => $paginator->linkCollection()->toArray(),
'path' => $paginator->path(),
'per_page' => $paginator->perPage(),
'to' => $paginator->lastItem(),
@@ -1,66 +0,0 @@
<?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.');
}
}
+9 -18
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\BankAccount;
use App\Models\Person\Person;
use Illuminate\Http\Request;
@@ -21,14 +22,14 @@ public function update(Person $person, Request $request)
'tax_number' => 'nullable|integer',
'social_security_number' => 'nullable|integer',
'description' => 'nullable|string|max:500',
'employer' => 'nullable|string|max:255',
'birthday' => 'nullable|date',
]);
$person->update($attributes);
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
}
public function createAddress(Person $person, Request $request)
@@ -71,7 +72,7 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$address->update($attributes);
return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
}
public function deleteAddress(Person $person, int $address_id, Request $request)
@@ -79,6 +80,7 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
$address = $person->addresses()->findOrFail($address_id);
$address->delete(); // soft delete
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
}
@@ -136,19 +138,12 @@ public function createEmail(Person $person, Request $request)
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
'failed' => 'boolean',
'receive_auto_mails' => 'sometimes|boolean',
'verified_at' => 'nullable|date',
'preferences' => 'nullable|array',
'meta' => 'nullable|array',
'decision_ids' => 'nullable|array',
'decision_ids.*' => 'integer|exists:decisions,id',
]);
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
unset($attributes['decision_ids']);
$attributes['preferences'] = array_merge($attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
// Dedup: avoid duplicate email per person by value
$email = $person->emails()->firstOrCreate([
'value' => $attributes['value'],
@@ -165,21 +160,14 @@ public function updateEmail(Person $person, int $email_id, Request $request)
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
'failed' => 'boolean',
'receive_auto_mails' => 'sometimes|boolean',
'verified_at' => 'nullable|date',
'preferences' => 'nullable|array',
'meta' => 'nullable|array',
'decision_ids' => 'nullable|array',
'decision_ids.*' => 'integer|exists:decisions,id',
]);
$email = $person->emails()->findOrFail($email_id);
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
unset($attributes['decision_ids']);
$attributes['preferences'] = array_merge($email->preferences ?? [], $attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
$email->update($attributes);
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
@@ -216,8 +204,10 @@ public function createTrr(Person $person, Request $request)
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
$trr = $person->bankAccounts()->create($attributes);
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
}
public function updateTrr(Person $person, int $trr_id, Request $request)
@@ -248,7 +238,8 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete();
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
}
}
+31 -30
View File
@@ -10,40 +10,42 @@
class PhoneViewController extends Controller
{
public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Request $request): \Inertia\Response
public function index(Request $request)
{
$userId = $request->user()->id;
$search = $request->input('search');
$clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$eagerLoad = [
'contract' => function ($q) {
$q->with([
'type:id,name',
'account',
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
];
$baseQuery = FieldJob::query()
$query = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->with($eagerLoad);
->with([
'contract' => function ($q) {
$q->with([
'type:id,name',
'account',
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
])
->orderByDesc('assigned_at');
// Apply client filter
if ($clientFilter) {
$baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
$q->where('uuid', $clientFilter);
});
}
// Apply search filter
if ($search) {
$baseQuery->where(function ($q) use ($search) {
$query->where(function ($q) use ($search) {
$q->whereHas('contract', function ($cq) use ($search) {
$cq->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($pq) use ($search) {
@@ -56,14 +58,9 @@ public function index(Request $request): \Inertia\Response
});
}
$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');
$jobs = $query->paginate($perPage)->withQueryString();
// Get unique clients for filter dropdown
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
$q->where('assigned_user_id', $userId)
@@ -80,8 +77,7 @@ public function index(Request $request): \Inertia\Response
->values();
return Inertia::render('Phone/Index', [
'pendingJobs' => Inertia::scroll(fn () => $pendingQuery->paginate(15, pageName: 'pending')),
'processedJobs' => Inertia::scroll(fn () => $processedQuery->paginate(15, pageName: 'processed')),
'jobs' => $jobs,
'clients' => $clients,
'view_mode' => 'assigned',
'filters' => [
@@ -91,11 +87,13 @@ public function index(Request $request): \Inertia\Response
]);
}
public function completedToday(Request $request): \Inertia\Response
public function completedToday(Request $request)
{
$userId = $request->user()->id;
$search = $request->input('search');
$clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$start = now()->startOfDay();
$end = now()->endOfDay();
@@ -140,6 +138,9 @@ public function completedToday(Request $request): \Inertia\Response
});
}
$jobs = $query->paginate($perPage)->withQueryString();
// Get unique clients for filter dropdown
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
$q->where('assigned_user_id', $userId)
@@ -156,7 +157,7 @@ public function completedToday(Request $request): \Inertia\Response
->values();
return Inertia::render('Phone/Index', [
'completedJobs' => Inertia::scroll(fn () => $query->paginate(15, pageName: 'completed')),
'jobs' => $jobs,
'clients' => $clients,
'view_mode' => 'completed-today',
'filters' => [
+6 -43
View File
@@ -43,7 +43,7 @@ public function show(string $slug, Request $request)
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
$perPage = (int) ($request->integer('per_page') ?: 25);
$query = $this->queryBuilder->build($report, $filters);
$paginator = $query->paginate($perPage);
@@ -279,51 +279,16 @@ public function clients(Request $request)
$clients = \App\Models\Client::query()
->with('person:id,full_name')
->get()
->map(fn ($c) => [
->map(fn($c) => [
'id' => $c->uuid,
'name' => $c->person->full_name ?? 'Unknown',
'name' => $c->person->full_name ?? 'Unknown'
])
->sortBy('name')
->values();
return response()->json($clients);
}
/**
* Lightweight actions lookup for select:action filters.
*/
public function actions(Request $request)
{
$actions = \App\Models\Action::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn ($a) => ['id' => $a->id, 'name' => $a->name])
->values();
return response()->json($actions);
}
/**
* Lightweight decisions lookup for select:decision filters.
* Optionally filtered by action_id (for dependent filter UI).
*/
public function decisions(Request $request)
{
$actionId = $request->integer('action_id', 0) ?: null;
$q = \App\Models\Decision::query()->orderBy('name');
if ($actionId !== null) {
$q->whereHas('actions', fn ($qq) => $qq->where('actions.id', $actionId));
}
$decisions = $q->get(['id', 'name'])
->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])
->values();
return response()->json($decisions);
}
/**
* Build validation rules based on inputs descriptor and validate.
*
@@ -342,8 +307,6 @@ protected function validateFilters(array $inputs, Request $request): array
'integer' => [$nullable, 'integer'],
'select:user' => [$nullable, 'integer', 'exists:users,id'],
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
'select:action' => [$nullable, 'integer', 'exists:actions,id'],
'select:decision' => [$nullable, 'integer', 'exists:decisions,id'],
default => [$nullable, 'string'],
};
}
@@ -356,7 +319,7 @@ protected function validateFilters(array $inputs, Request $request): array
*/
protected function buildInputsArray(Report $report): array
{
return $report->filters->map(fn ($filter) => [
return $report->filters->map(fn($filter) => [
'key' => $filter->key,
'type' => $filter->type,
'label' => $filter->label,
@@ -373,7 +336,7 @@ protected function buildColumnsArray(Report $report): array
{
return $report->columns
->where('visible', true)
->map(fn ($col) => [
->map(fn($col) => [
'key' => $col->key,
'label' => $col->label,
])
+4 -29
View File
@@ -7,7 +7,6 @@
use App\Models\Decision;
use App\Models\EmailTemplate;
use App\Models\Segment;
use App\Services\DecisionEvents\ConditionEvaluator;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
@@ -23,8 +22,6 @@ public function index(Request $request)
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
'condition_fields' => ConditionEvaluator::availableFields(),
'condition_operators' => ConditionEvaluator::availableOperators(),
]);
}
@@ -86,9 +83,6 @@ public function updateAction(int $id, Request $request)
public function storeDecision(Request $request)
{
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
$attributes = $request->validate([
'name' => 'required|string|max:50',
'color_tag' => 'nullable|string|max:25',
@@ -102,14 +96,6 @@ public function storeDecision(Request $request)
'events.*.active' => 'sometimes|boolean',
'events.*.run_order' => 'nullable|integer',
'events.*.config' => 'nullable|array',
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
'events.*.config.deactivate_previous' => 'sometimes|boolean',
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
'events.*.config.reactivate' => 'sometimes|boolean',
'events.*.config.conditions' => 'nullable|array',
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
]);
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
@@ -126,12 +112,12 @@ public function storeDecision(Request $request)
$key = $eventModel?->key ?? ($ev['key'] ?? null);
if ($key === 'add_segment') {
$seg = $ev['config']['segment_id'] ?? null;
if (empty($seg)) {
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
}
} elseif ($key === 'archive_contract') {
$as = $ev['config']['archive_setting_id'] ?? null;
if (empty($as)) {
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
}
}
@@ -188,9 +174,6 @@ public function updateDecision(int $id, Request $request)
{
$row = Decision::findOrFail($id);
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
$attributes = $request->validate([
'name' => 'required|string|max:50',
'color_tag' => 'nullable|string|max:25',
@@ -204,14 +187,6 @@ public function updateDecision(int $id, Request $request)
'events.*.active' => 'sometimes|boolean',
'events.*.run_order' => 'nullable|integer',
'events.*.config' => 'nullable|array',
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
'events.*.config.deactivate_previous' => 'sometimes|boolean',
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
'events.*.config.reactivate' => 'sometimes|boolean',
'events.*.config.conditions' => 'nullable|array',
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
]);
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
@@ -228,12 +203,12 @@ public function updateDecision(int $id, Request $request)
$key = $eventModel?->key ?? ($ev['key'] ?? null);
if ($key === 'add_segment') {
$seg = $ev['config']['segment_id'] ?? null;
if (empty($seg)) {
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
}
} elseif ($key === 'archive_contract') {
$as = $ev['config']['archive_setting_id'] ?? null;
if (empty($as)) {
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
}
}
@@ -59,15 +59,6 @@ public function share(Request $request): array
'info' => fn () => $request->session()->get('info'),
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
],
'callLaterCount' => function () use ($request) {
if (! $request->user()) {
return 0;
}
return \App\Models\CallLater::query()
->whereNull('completed_at')
->count();
},
'notifications' => function () use ($request) {
try {
$user = $request->user();
@@ -1,35 +0,0 @@
<?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,9 +23,6 @@ public function rules(): array
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
'allow_attachments' => ['sometimes', 'boolean'],
'active' => ['boolean'],
'client' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
];
}
}
@@ -1,24 +0,0 @@
<?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,9 +26,6 @@ public function rules(): array
'reply_to_name' => ['nullable', 'string', 'max:190'],
'priority' => ['nullable', 'integer', 'between:0,65535'],
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
'signature' => ['nullable', 'array'],
'signature.*' => ['nullable', 'string', 'max:1000'],
'auto_mailer' => ['nullable', 'boolean'],
];
}
}
@@ -1,23 +0,0 @@
<?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,9 +25,6 @@ public function rules(): array
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
'allow_attachments' => ['sometimes', 'boolean'],
'active' => ['boolean'],
'client' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
];
}
}
@@ -1,24 +0,0 @@
<?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,9 +27,6 @@ public function rules(): array
'priority' => ['nullable', 'integer', 'between:0,65535'],
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
'active' => ['nullable', 'boolean'],
'signature' => ['nullable', 'array'],
'signature.*' => ['nullable', 'string', 'max:1000'],
'auto_mailer' => ['nullable', 'boolean'],
];
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\RedirectResponse;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
class LoginResponse implements LoginResponseContract
{
public function toResponse($request): RedirectResponse
{
$user = $request->user();
$default = $user?->login_redirect ?: config('fortify.home');
return redirect()->intended($default);
}
}
-296
View File
@@ -1,296 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Contract;
use App\Models\Email;
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Models\EmailTemplate;
use App\Models\MailProfile;
use App\Models\Package;
use App\Models\PackageItem;
use App\Services\EmailSender;
use App\Services\EmailTemplateRenderer;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class PackageItemEmailJob implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $packageItemId)
{
$this->onQueue('email');
}
public function handle(EmailTemplateRenderer $renderer, EmailSender $sender): void
{
/** @var PackageItem|null $item */
$item = PackageItem::query()->find($this->packageItemId);
if (! $item) {
return;
}
/** @var Package $package */
$package = $item->package;
if (! $package || $package->status === Package::STATUS_CANCELED) {
return;
}
if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) {
return;
}
if ($item->status === 'queued') {
$item->status = 'processing';
$item->save();
$package->increment('processing_count');
}
$payload = (array) $item->payload_json;
$target = (array) $item->target_json;
$to = $target['email'] ?? null;
if (! is_string($to) || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
$item->status = 'failed';
$item->last_error = 'Missing or invalid recipient email.';
$item->save();
$this->updatePackageCounters($item, $package);
return;
}
$templateId = $payload['template_id'] ?? null;
$mailProfileId = $payload['mail_profile_id'] ?? null;
$variables = (array) ($payload['variables'] ?? []);
$subjectOverride = isset($payload['subject']) ? trim((string) $payload['subject']) : null;
if ($subjectOverride === '') {
$subjectOverride = null;
}
$bodyText = isset($payload['body_text']) ? (string) $payload['body_text'] : '';
// Enrich variables with contract/account context when available
$contract = null;
if (! empty($target['contract_id'])) {
$contract = Contract::query()->with(['clientCase.person', 'account.type'])->find($target['contract_id']);
if ($contract) {
$variables['contract'] = [
'id' => $contract->id,
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'start_date' => (string) ($contract->start_date ?? ''),
'end_date' => (string) ($contract->end_date ?? ''),
];
if (is_array($contract->meta) && ! empty($contract->meta)) {
$variables['contract']['meta'] = $this->flattenMeta($contract->meta);
}
if ($contract->account) {
$initialRaw = (string) $contract->account->initial_amount;
$balanceRaw = (string) $contract->account->balance_amount;
$variables['account'] = [
'id' => $contract->account->id,
'reference' => $contract->account->reference,
'initial_amount' => $this->formatAmountEu($initialRaw),
'balance_amount' => $this->formatAmountEu($balanceRaw),
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
'type' => $contract->account->type?->name,
];
}
if ($contract->clientCase?->person) {
$person = $contract->clientCase->person;
$variables['person'] = [
'full_name' => $person->full_name,
'first_name' => $person->first_name,
'last_name' => $person->last_name,
];
}
}
}
/** @var EmailTemplate|null $template */
$template = $templateId ? EmailTemplate::with(['action', 'decision'])->find((int) $templateId) : null;
/** @var MailProfile|null $mailProfile */
$mailProfile = $mailProfileId
? MailProfile::find((int) $mailProfileId)
: MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
try {
if (! $template && ! $subjectOverride) {
throw new \RuntimeException('No email template or subject provided.');
}
$rendered = $template
? $renderer->render([
'subject' => $subjectOverride ?? (string) $template->subject_template,
'html' => (string) $template->html_template,
'text' => (string) $template->text_template,
], array_filter([
'contract' => $contract,
'person' => $contract?->clientCase?->person,
'client' => $contract?->clientCase?->client,
'client_case' => $contract?->clientCase,
'mail_profile' => $mailProfile,
'extra' => $variables,
'body_text' => $bodyText !== '' ? $bodyText : null,
]))
: [
'subject' => $subjectOverride ?? '',
'html' => null,
'text' => null,
];
$log = new EmailLog;
$log->fill([
'uuid' => (string) Str::uuid(),
'template_id' => $template?->id,
'mail_profile_id' => $mailProfile?->id,
'to_email' => $to,
'to_recipients' => [$to],
'subject' => $rendered['subject'],
'body_html_hash' => isset($rendered['html']) ? hash('sha256', (string) $rendered['html']) : null,
'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null,
'embed_mode' => 'base64',
'status' => EmailLogStatus::Queued,
'queued_at' => now(),
'contract_id' => $contract?->id,
'client_id' => $contract?->clientCase?->client?->id,
'client_case_id' => $contract?->clientCase?->id,
'extra_context' => ['package_id' => $item->package_id, 'package_item_id' => $item->id],
]);
$log->save();
$log->body()->create([
'body_html' => (string) ($rendered['html'] ?? ''),
'body_text' => (string) ($rendered['text'] ?? ''),
'inline_css' => true,
]);
// Send directly (synchronous within job context)
$start = microtime(true);
$log->status = EmailLogStatus::Sending;
$log->started_at = now();
$log->attempt = 1;
$log->save();
$sender->sendFromLog($log);
$log->status = EmailLogStatus::Sent;
$log->sent_at = now();
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
$log->save();
$item->status = 'sent';
$item->result_json = ['email_log_id' => $log->id, 'subject' => $rendered['subject']];
$item->last_error = null;
$item->save();
// Clear failed flag on successful delivery
Email::query()->where('value', $to)->where('failed', true)->update(['failed' => false]);
// Create activity if the template has action/decision configured
if ($template && ($template->action_id || $template->decision_id) && $contract && $contract->client_case_id) {
$activity = \App\Models\Activity::create(array_filter([
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => 'Poslano: '.$to.', Uspešno'.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
]));
$activity->emailLogs()->attach($log->id);
}
} catch (\Throwable $e) {
$item->status = 'failed';
$item->last_error = $e->getMessage();
$item->save();
// Create activity for failed send if the template has action/decision configured
if ($template && ($template->action_id || $template->decision_id) && isset($contract) && $contract && $contract->client_case_id) {
$shortError = mb_strimwidth($e->getMessage(), 0, 120, '…');
$activity = \App\Models\Activity::create(array_filter([
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => 'Poslano: '.$to.', Napaka pri pošiljanju: '.$shortError.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
]));
if (isset($log) && $log->exists) {
$activity->emailLogs()->attach($log->id);
}
}
// Mark the email address as failed in the DB.
if (isset($to)) {
Email::query()
->where('value', $to)
->update(['failed' => true]);
}
// Permanent SMTP rejection (550 user unknown, 551 not local, 553 invalid address)
// means the address definitively does not exist — also mark it invalid.
if ($e instanceof \Symfony\Component\Mailer\Exception\TransportExceptionInterface
&& preg_match('/\b55[013]\b/', $e->getMessage())
&& isset($to)) {
Email::query()
->where('value', $to)
->update(['valid' => false]);
}
}
$this->updatePackageCounters($item, $package);
}
private function updatePackageCounters(PackageItem $item, Package $package): void
{
if ($item->status === 'sent') {
$package->increment('sent_count');
} else {
$package->increment('failed_count');
}
$package->decrement('processing_count');
$package->refresh();
$done = $package->sent_count + $package->failed_count;
if ($done >= $package->total_items) {
$package->status = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
$package->finished_at = now();
$package->save();
}
}
private function flattenMeta(array $meta, string $prefix = ''): array
{
$result = [];
foreach ($meta as $key => $value) {
$newKey = $prefix === '' ? $key : "{$prefix}.{$key}";
if (is_array($value)) {
if (isset($value['value'])) {
$result[$newKey] = $value['value'];
} else {
$nested = $this->flattenMeta($value, $newKey);
$result = array_merge($result, $nested);
}
} else {
$result[$newKey] = $value;
}
}
return $result;
}
private function formatAmountEu(string $raw): string
{
$numeric = preg_replace('/[^0-9.]/', '', $raw);
$float = (float) $numeric;
return number_format($float, 2, ',', '.');
}
}
-18
View File
@@ -4,7 +4,6 @@
use App\Models\Activity;
use App\Models\Event as DecisionEventModel;
use App\Services\DecisionEvents\ConditionEvaluator;
use App\Services\DecisionEvents\DecisionEventContext;
use App\Services\DecisionEvents\Registry;
use Illuminate\Bus\Queueable;
@@ -69,23 +68,6 @@ public function handle(): void
user: $activity->user,
);
// [2] Condition check — skip the event if any condition is not met
$conditions = $this->config['conditions'] ?? [];
if (! empty($conditions)) {
$conditionsMet = app(ConditionEvaluator::class)->evaluate($conditions, $context);
if (! $conditionsMet) {
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
'status' => 'skipped',
'message' => 'Condition not met',
'finished_at' => now(),
'updated_at' => now(),
]);
return;
}
}
// [3] Resolve handler → handle()
$handler->handle($context, $this->config);
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
-5
View File
@@ -2,7 +2,6 @@
namespace App\Jobs;
use App\Models\Email;
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Services\EmailSender;
@@ -54,10 +53,6 @@ public function handle(): void
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
$log->save();
if ($log->to_email) {
Email::query()->where('value', $log->to_email)->update(['failed' => true]);
}
throw $e;
}
}
+1 -7
View File
@@ -10,10 +10,9 @@
class Account extends Model
{
use HasFactory;
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes;
use HasFactory;
protected $fillable = [
'reference',
@@ -59,11 +58,6 @@ public function payments(): HasMany
return $this->hasMany(\App\Models\Payment::class);
}
public function installments(): HasMany
{
return $this->hasMany(\App\Models\Installment::class);
}
public function bookings(): HasMany
{
return $this->hasMany(\App\Models\Booking::class);
-19
View File
@@ -7,7 +7,6 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Activity extends Model
@@ -19,7 +18,6 @@ class Activity extends Model
protected $fillable = [
'due_date',
'call_back_at',
'amount',
'note',
'action_id',
@@ -29,13 +27,6 @@ class Activity extends Model
'client_case_id',
];
/*protected function casts(): array
{
return [
'call_back_at' => 'datetime',
];
}*/
protected $hidden = [
'action_id',
'decision_id',
@@ -155,14 +146,4 @@ public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class);
}
public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(\App\Models\CallLater::class);
}
public function emailLogs(): BelongsToMany
{
return $this->belongsToMany(EmailLog::class, 'activity_email_logs');
}
}
-46
View File
@@ -1,46 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CallLater extends Model
{
protected $fillable = [
'activity_id',
'client_case_id',
'contract_id',
'user_id',
'call_back_at',
'completed_at',
];
protected function casts(): array
{
return [
'call_back_at' => 'datetime',
'completed_at' => 'datetime',
];
}
public function activity(): BelongsTo
{
return $this->belongsTo(Activity::class);
}
public function clientCase(): BelongsTo
{
return $this->belongsTo(ClientCase::class);
}
public function contract(): BelongsTo
{
return $this->belongsTo(Contract::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
-15
View File
@@ -1,15 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ContractSetting extends Model
{
protected $fillable = [
'create_activity_on_balance_change',
'default_action_id',
'default_decision_id',
'activity_note_template',
];
}
-2
View File
@@ -18,7 +18,6 @@ class Email extends Model
'is_primary',
'is_active',
'valid',
'failed',
'receive_auto_mails',
'verified_at',
'preferences',
@@ -29,7 +28,6 @@ class Email extends Model
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
'failed' => 'boolean',
'receive_auto_mails' => 'boolean',
'verified_at' => 'datetime',
'preferences' => 'array',
-6
View File
@@ -4,7 +4,6 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
enum EmailLogStatus: string
@@ -84,9 +83,4 @@ public function body(): HasOne
{
return $this->hasOne(EmailLogBody::class, 'email_log_id');
}
public function activities(): BelongsToMany
{
return $this->belongsToMany(Activity::class, 'activity_email_logs');
}
}
-15
View File
@@ -4,7 +4,6 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class EmailTemplate extends Model
@@ -20,14 +19,10 @@ class EmailTemplate extends Model
'entity_types',
'allow_attachments',
'active',
'action_id',
'decision_id',
'client',
];
protected $casts = [
'active' => 'boolean',
'client' => 'boolean',
'entity_types' => 'array',
'allow_attachments' => 'boolean',
];
@@ -36,14 +31,4 @@ public function documents(): MorphMany
{
return $this->morphMany(Document::class, 'documentable');
}
public function action(): BelongsTo
{
return $this->belongsTo(Action::class);
}
public function decision(): BelongsTo
{
return $this->belongsTo(Decision::class);
}
}
-46
View File
@@ -1,46 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Installment extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = [
'account_id',
'amount',
'balance_before',
'currency',
'reference',
'installment_at',
'meta',
'created_by',
'activity_id',
];
protected function casts(): array
{
return [
'installment_at' => 'datetime',
'meta' => 'array',
'amount' => 'decimal:4',
'balance_before' => 'decimal:4',
];
}
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
public function activity(): BelongsTo
{
return $this->belongsTo(Activity::class);
}
}
-19
View File
@@ -1,19 +0,0 @@
<?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',
];
}
+2 -4
View File
@@ -10,15 +10,13 @@ class MailProfile extends Model
use HasFactory;
protected $fillable = [
'name', 'active', 'auto_mailer', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
'reply_to_address', 'reply_to_name', 'priority', 'signature', 'max_daily_quota', 'emails_sent_today',
'name', 'active', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
'reply_to_address', 'reply_to_name', 'priority', 'max_daily_quota', 'emails_sent_today',
'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at',
];
protected $casts = [
'active' => 'boolean',
'auto_mailer' => 'boolean',
'signature' => 'array',
'last_success_at' => 'datetime',
'last_error_at' => 'datetime',
'test_checked_at' => 'datetime',
-2
View File
@@ -34,8 +34,6 @@ public function items()
public const TYPE_SMS = 'sms';
public const TYPE_EMAIL = 'email';
public const STATUS_DRAFT = 'draft';
public const STATUS_QUEUED = 'queued';
-1
View File
@@ -31,7 +31,6 @@ class User extends Authenticatable
'email',
'password',
'active',
'login_redirect',
];
/**
+1 -3
View File
@@ -6,7 +6,6 @@
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Http\Responses\LoginResponse;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
@@ -15,7 +14,6 @@
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
@@ -25,7 +23,7 @@ class FortifyServiceProvider extends ServiceProvider
*/
public function register(): void
{
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
//
}
/**
+3 -41
View File
@@ -59,23 +59,10 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
// Resolve eligible recipients: client's person emails with receive_auto_mails = true
$recipients = [];
if ($client && $client->person) {
$emails = Email::query()
$recipients = Email::query()
->where('person_id', $client->person->id)
->where('is_active', true)
->where('receive_auto_mails', true)
->get(['value', 'preferences']);
$recipients = $emails
->filter(function (Email $email) use ($decision): bool {
$decisionIds = $email->preferences['decision_ids'] ?? [];
// Empty list means "all decisions" — always receive
if (empty($decisionIds)) {
return true;
}
return in_array((int) $decision->id, array_map('intval', $decisionIds), true);
})
->pluck('value')
->map(fn ($v) => strtolower(trim((string) $v)))
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
@@ -90,30 +77,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
// Ensure related names are available without extra queries
$activity->loadMissing(['action', 'decision']);
// Ensure account is available on contract (needed for contract.account.* tokens)
if ($contract && ! $contract->relationLoaded('account')) {
$contract->load('account');
}
// Resolve the sending profile once — used both for signature tokens and as the actual sender.
// Prefer the profile explicitly requested via options, fall back to highest-priority active one.
$mailProfile = isset($options['mail_profile_id'])
? MailProfile::query()->find($options['mail_profile_id'])
: null;
$mailProfile ??= MailProfile::query()
->where('active', true)
->where('auto_mailer', true)
->orderBy('priority')
->orderBy('id')
->first();
$mailProfile ??= MailProfile::query()
->where('active', true)
->orderBy('priority')
->orderBy('id')
->first();
// Render content
$bodyText = isset($options['body_text']) ? (string) $options['body_text'] : '';
$rendered = $this->renderer->render([
'subject' => (string) $template->subject_template,
'html' => (string) $template->html_template,
@@ -125,8 +89,6 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
'person' => $person,
'activity' => $activity,
'extra' => [],
'mail_profile' => $mailProfile,
'body_text' => $bodyText,
]);
// Create the log and body
@@ -134,7 +96,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
$log->fill([
'uuid' => (string) \Str::uuid(),
'template_id' => $template->id,
'mail_profile_id' => $mailProfile?->id,
'mail_profile_id' => optional(MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first())->id,
'user_id' => auth()->id(),
'to_email' => (string) ($recipients[0] ?? ''),
'to_recipients' => $recipients,
@@ -174,7 +136,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
$log->body()->create([
'body_html' => (string) ($rendered['html'] ?? ''),
'body_text' => $bodyText !== '' ? $bodyText : (string) ($rendered['text'] ?? ''),
'body_text' => (string) ($rendered['text'] ?? ''),
'inline_css' => true,
]);
+6 -4
View File
@@ -11,9 +11,9 @@
class ClientCaseDataService
{
/**
* Get contracts for a client case with optional segment filtering.
* Get paginated contracts for a client case with optional segment filtering.
*/
public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Collection
public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int $perPage = 50): LengthAwarePaginator
{
$query = $clientCase->contracts()
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
@@ -40,7 +40,9 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Co
$query->forSegment($segmentId);
}
return $query->get();
$perPage = max(1, min(100, $perPage));
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
}
/**
@@ -54,7 +56,7 @@ public function getActivities(
int $perPage = 20
): LengthAwarePaginator {
$query = $clientCase->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name', 'emailLogs:id'])
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at');
if (! empty($segmentId)) {
-54
View File
@@ -1,54 +0,0 @@
<?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];
}
}
@@ -1,123 +0,0 @@
<?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,14 +36,6 @@ public function handle(DecisionEventContext $context, array $config = []): void
$setting->reactivate = (bool) $config['reactivate'];
}
// Cancel all active FieldJobs for this contract before archiving (raw update to avoid boot-event side effects)
\DB::table('field_jobs')
->where('contract_id', $contractId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->whereNull('deleted_at')
->update(['cancelled_at' => now(), 'updated_at' => now()]);
$results = app(ArchiveExecutor::class)->executeSetting(
$setting,
['contract_id' => $contractId],
@@ -1,27 +0,0 @@
<?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,
]);
}
}
+1 -5
View File
@@ -17,19 +17,15 @@ class Registry
'add_segment' => AddSegmentHandler::class,
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class,
'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class,
'add_call_later' => \App\Services\DecisionEvents\Handlers\CallLaterHandler::class,
];
public static function resolve(string $key): DecisionEventHandler
{
$key = trim(strtolower($key));
$class = static::$map[$key] ?? null;
if (! $class) {
if (! $class || ! class_exists($class)) {
throw new InvalidArgumentException("Unknown decision event handler for key: {$key}");
}
if (! class_exists($class)) {
throw new InvalidArgumentException("Handler class {$class} for key {$key} does not exist (check autoload)");
}
$handler = app($class);
if (! $handler instanceof DecisionEventHandler) {
throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler");
+35
View File
@@ -152,6 +152,19 @@ public function sendFromLog(EmailLog $log): array
$email->to(new Address($singleTo, (string) ($log->to_name ?? '')));
}
// Always BCC the sender mailbox if present and not already in To
$senderBcc = null;
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
// Check duplicates against toList
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
$senderBcc = $fromAddr;
$email->bcc(new Address($senderBcc));
// Persist BCC for auditing
$log->bcc = [$senderBcc];
}
}
if (! empty($text)) {
$email->text($text);
}
@@ -291,6 +304,10 @@ public function sendFromLog(EmailLog $log): array
}
$mailer->send($email);
// Save log if we modified BCC
if (! empty($log->getAttribute('bcc'))) {
$log->save();
}
$headers = $email->getHeaders();
$messageIdHeader = $headers->get('Message-ID');
$messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null;
@@ -313,6 +330,15 @@ public function sendFromLog(EmailLog $log): array
$message->to($singleTo);
}
}
// BCC the sender mailbox if resolvable and not already in To
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
$message->bcc($fromAddr);
$log->bcc = [$fromAddr];
}
}
$message->subject($subject);
if (! empty($log->reply_to)) {
$message->replyTo($log->reply_to);
@@ -438,6 +464,15 @@ public function sendFromLog(EmailLog $log): array
$message->to($singleTo);
}
}
// BCC the sender mailbox if resolvable and not already in To
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
$lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
$message->bcc($fromAddr);
$log->bcc = [$fromAddr];
}
}
$message->subject($subject);
if (! empty($log->reply_to)) {
$message->replyTo($log->reply_to);
+6 -87
View File
@@ -30,49 +30,17 @@ public function render(array $template, array $ctx): array
return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) {
$key = $m[1];
// 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;
return (string) data_get($map, $key, '');
}, $input);
};
$bodyText = isset($ctx['body_text']) ? (string) $ctx['body_text'] : '';
return [
'subject' => $replacer($template['subject']) ?? '',
'html' => $this->applyBodyText($replacer($template['html'] ?? null) ?? null, $bodyText, html: true),
'text' => $this->applyBodyText($replacer($template['text'] ?? null) ?? null, $bodyText, html: false),
'html' => $replacer($template['html'] ?? null) ?? null,
'text' => $replacer($template['text'] ?? null) ?? null,
];
}
/**
* 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
*/
@@ -177,18 +145,12 @@ protected function buildMap(array $ctx): array
'id' => data_get($co, 'id'),
'uuid' => data_get($co, 'uuid'),
'reference' => data_get($co, 'reference'),
// Account amounts — sourced from the related Account model
'account' => [
'balance_amount' => $formatMoneyEu(data_get($co, 'account.balance_amount')),
'initial_amount' => $formatMoneyEu(data_get($co, 'account.initial_amount')),
],
// Format amounts in EU style for emails
'amount' => $formatMoneyEu(data_get($co, 'amount')),
];
$meta = data_get($co, 'meta');
if (is_string($meta)) {
$meta = json_decode($meta, true) ?? [];
}
if (is_array($meta)) {
$out['contract']['meta'] = $this->flattenMetaForTemplate($meta);
$out['contract']['meta'] = $meta;
}
}
if (isset($ctx['activity'])) {
@@ -210,50 +172,7 @@ protected function buildMap(array $ctx): array
if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
$out['extra'] = $ctx['extra'];
}
if (isset($ctx['mail_profile'])) {
$mp = $ctx['mail_profile'];
$out['profile'] = [
'signature' => is_array($mp->signature) ? $mp->signature : [],
];
}
return $out;
}
/**
* Flatten a contract meta array so every leaf value is accessible by its bare key.
*
* Handles three formats stored in the wild:
* 1. Numeric wrapper: { "1": { "sklic": "SI00…", "job_days": 1 } }
* { "sklic": "SI00…", "job_days": 1 }
* 2. Structured entry: { "sklic": { "value": "SI00…", "type": "string" } }
* { "sklic": "SI00…" }
* 3. Already flat: { "sklic": "SI00…" }
* { "sklic": "SI00…" }
*/
private function flattenMetaForTemplate(array $meta): array
{
$flat = [];
foreach ($meta as $key => $item) {
if (!is_array($item)) {
// Plain scalar — keep as-is (format 3)
if (!array_key_exists($key, $flat)) {
$flat[$key] = $item;
}
} elseif (array_key_exists('value', $item)) {
// Structured { value, type, title } entry (format 2)
$flat[$key] = $item['value'];
} elseif (is_numeric($key)) {
// Numeric wrapper key — recurse and alias without the prefix (format 1)
foreach ($this->flattenMetaForTemplate($item) as $nk => $nv) {
if (!array_key_exists($nk, $flat)) {
$flat[$nk] = $nv;
}
}
}
// Non-numeric nested arrays without a 'value' key are silently skipped
}
return $flat;
}
}
+8 -4
View File
@@ -10,21 +10,21 @@
"barryvdh/laravel-dompdf": "^3.1",
"diglactic/laravel-breadcrumbs": "^10.0",
"http-interop/http-factory-guzzle": "^1.2",
"inertiajs/inertia-laravel": "^3.0",
"laravel/framework": "^12.0",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "12.0",
"laravel/jetstream": "^5.2",
"laravel/sanctum": "^4.0",
"laravel/scout": "^10.11",
"laravel/tinker": "^2.9",
"maatwebsite/excel": "^3.1",
"meilisearch/meilisearch-php": "^1.11",
"robertboes/inertia-breadcrumbs": "^1.0",
"robertboes/inertia-breadcrumbs": "dev-laravel-12",
"tightenco/ziggy": "^2.0",
"tijsverkoyen/css-to-inline-styles": "^2.2"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^2.2",
"laravel/boost": "^1.1",
"laravel/pint": "^1.13",
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",
@@ -45,6 +45,10 @@
}
},
"scripts": {
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fdba74\" \"php artisan serve --no-reload --port=8090\" \"php artisan queue:listen --tries=1\" \"npm run dev\" --names='server,queue,vite'"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
Generated
+744 -1029
View File
File diff suppressed because it is too large Load Diff
@@ -1,28 +0,0 @@
<?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');
});
}
};
@@ -1,33 +0,0 @@
<?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');
}
};
@@ -1,31 +0,0 @@
<?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');
}
};
@@ -1,26 +0,0 @@
<?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');
}
};
@@ -1,31 +0,0 @@
<?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');
}
};
@@ -1,21 +0,0 @@
<?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');
}
};
@@ -1,28 +0,0 @@
<?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');
});
}
};
@@ -1,22 +0,0 @@
<?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');
});
}
};
@@ -1,25 +0,0 @@
<?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']);
});
}
};
@@ -1,22 +0,0 @@
<?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');
});
}
};
@@ -1,25 +0,0 @@
<?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');
}
};
@@ -1,28 +0,0 @@
<?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');
});
}
};
@@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('email_templates', function (Blueprint $table): void {
$table->boolean('client')->default(false)->after('active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('email_templates', function (Blueprint $table): void {
$table->dropColumn('client');
});
}
};
-5
View File
@@ -31,11 +31,6 @@ public function run(): void
'name' => 'End field job',
'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).',
],
[
'key' => 'add_call_later',
'name' => 'Klic kasneje',
'description' => 'Ustvari zapis za povratni klic ob določenem datumu in uri.',
],
];
foreach ($rows as $row) {
-262
View File
@@ -21,7 +21,6 @@ public function run(): void
$this->seedSegmentActivityCountsReport();
$this->seedActionsDecisionsCountReport();
$this->seedActivitiesPerPeriodReport();
$this->seedActivitiesDetailReport();
}
protected function seedActiveContractsReport(): void
@@ -784,265 +783,4 @@ protected function seedActivitiesPerPeriodReport(): void
'order' => 0,
]);
}
protected function seedActivitiesDetailReport(): void
{
$report = Report::create([
'slug' => 'activities-detail',
'name' => 'Aktivnosti pregled',
'description' => 'Podroben pregled aktivnosti z možnostjo filtriranja po stranki, datumu, akciji in odločitvi.',
'category' => 'activities',
'enabled' => true,
'order' => 7,
]);
// Entities (joins)
$report->entities()->create([
'model_class' => 'App\\Models\\Activity',
'join_type' => 'base',
'order' => 0,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Action',
'join_type' => 'leftJoin',
'join_first' => 'activities.action_id',
'join_operator' => '=',
'join_second' => 'actions.id',
'order' => 1,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Decision',
'join_type' => 'leftJoin',
'join_first' => 'activities.decision_id',
'join_operator' => '=',
'join_second' => 'decisions.id',
'order' => 2,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Contract',
'join_type' => 'leftJoin',
'join_first' => 'activities.contract_id',
'join_operator' => '=',
'join_second' => 'contracts.id',
'order' => 3,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\ClientCase',
'join_type' => 'leftJoin',
'join_first' => 'activities.client_case_id',
'join_operator' => '=',
'join_second' => 'client_cases.id',
'order' => 4,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Client',
'join_type' => 'leftJoin',
'join_first' => 'client_cases.client_id',
'join_operator' => '=',
'join_second' => 'clients.id',
'order' => 5,
]);
$report->entities()->createMany([
[
'model_class' => 'App\\Models\\Person\\Person',
'alias' => 'client_people',
'join_type' => 'leftJoin',
'join_first' => 'clients.person_id',
'join_operator' => '=',
'join_second' => 'client_people.id',
'order' => 6,
],
[
'model_class' => 'App\\Models\\Person\\Person',
'alias' => 'subject_people',
'join_type' => 'leftJoin',
'join_first' => 'client_cases.person_id',
'join_operator' => '=',
'join_second' => 'subject_people.id',
'order' => 7,
],
]);
// Columns
$report->columns()->createMany([
[
'key' => 'contract_reference',
'label' => 'Pogodba',
'type' => 'string',
'expression' => 'contracts.reference',
'sortable' => true,
'visible' => true,
'order' => 0,
],
[
'key' => 'naziv',
'label' => 'Naziv',
'type' => 'string',
'expression' => 'subject_people.full_name',
'sortable' => true,
'visible' => true,
'order' => 1,
],
[
'key' => 'stranka',
'label' => 'Stranka',
'type' => 'string',
'expression' => 'client_people.full_name',
'sortable' => true,
'visible' => true,
'order' => 2,
],
[
'key' => 'aktivnost',
'label' => 'Aktivnost',
'type' => 'string',
'expression' => "CONCAT(COALESCE(actions.name, ''), ' / ', COALESCE(decisions.name, ''))",
'sortable' => false,
'visible' => true,
'order' => 3,
],
[
'key' => 'datum',
'label' => 'Datum',
'type' => 'date',
'expression' => 'DATE(activities.created_at)',
'sortable' => true,
'visible' => true,
'order' => 4,
],
[
'key' => 'opomba',
'label' => 'Opomba',
'type' => 'string',
'expression' => 'activities.note',
'sortable' => false,
'visible' => true,
'order' => 5,
],
[
'key' => 'zapadlost',
'label' => 'Zapadlost',
'type' => 'date',
'expression' => 'activities.due_date',
'sortable' => true,
'visible' => true,
'order' => 6,
],
[
'key' => 'znesek',
'label' => 'Znesek',
'type' => 'currency',
'expression' => 'activities.amount',
'sortable' => true,
'visible' => true,
'order' => 7,
],
]);
// Filters
$report->filters()->createMany([
[
'key' => 'client_uuid',
'label' => 'Stranka',
'type' => 'select:client',
'nullable' => true,
'order' => 0,
],
[
'key' => 'from',
'label' => 'Datum od',
'type' => 'date',
'nullable' => true,
'order' => 1,
],
[
'key' => 'to',
'label' => 'Datum do',
'type' => 'date',
'nullable' => true,
'order' => 2,
],
[
'key' => 'action_id',
'label' => 'Akcija',
'type' => 'select:action',
'nullable' => true,
'order' => 3,
],
[
'key' => 'decision_id',
'label' => 'Odločitev',
'type' => 'select:decision',
'nullable' => true,
'order' => 4,
],
]);
// Conditions (all filter-based, skipped when null)
$report->conditions()->createMany([
[
'column' => 'activities.created_at',
'operator' => '>=',
'value_type' => 'filter',
'filter_key' => 'from',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.created_at',
'operator' => '<=',
'value_type' => 'filter',
'filter_key' => 'to',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 1,
'enabled' => true,
],
[
'column' => 'clients.uuid',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'client_uuid',
'logical_operator' => 'AND',
'group_id' => 2,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.action_id',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'action_id',
'logical_operator' => 'AND',
'group_id' => 3,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.decision_id',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'decision_id',
'logical_operator' => 'AND',
'group_id' => 4,
'order' => 0,
'enabled' => true,
],
]);
// Order
$report->orders()->create([
'column' => 'activities.created_at',
'direction' => 'DESC',
'order' => 0,
]);
}
}
+19
View File
@@ -0,0 +1,19 @@
// @ts-check
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
+6102 -5960
View File
File diff suppressed because it is too large Load Diff
+67 -66
View File
@@ -1,68 +1,69 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"devDependencies": {
"@inertiajs/vue3": "^3.0",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.3",
"@vitejs/plugin-vue": "^6.0.2",
"autoprefixer": "^10.4.22",
"axios": "^1.13.2",
"laravel-vite-plugin": "^2.0.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.16",
"typescript": "^5.9.3",
"vite": "^7.2.7",
"vue": "^3.3.13",
"vue-tsc": "^3.1.8"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.1.2",
"@guolao/vue-monaco-editor": "^1.6.0",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@internationalized/date": "^3.10.0",
"@tanstack/vue-table": "^8.21.3",
"@unovis/ts": "^1.6.2",
"@unovis/vue": "^1.6.2",
"@vee-validate/zod": "^4.15.1",
"@vuepic/vue-datepicker": "^11.0.3",
"@vueuse/core": "^14.1.0",
"apexcharts": "^4.7.0",
"class-variance-authority": "^0.7.1",
"clean": "^4.0.2",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"lucide-vue-next": "^0.552.0",
"material-design-icons-iconfont": "^6.7.0",
"monaco-editor": "^0.55.1",
"preline": "^2.7.0",
"quill": "^1.3.7",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-inner-border": "^0.2.0",
"v-calendar": "^3.1.2",
"vee-validate": "^4.15.1",
"vue-currency-input": "^3.2.1",
"vue-multiselect": "^3.4.0",
"vue-search-input": "^1.1.19",
"vue-sonner": "^2.0.9",
"vue3-apexcharts": "^1.10.0",
"vuedraggable": "^4.1.0",
"zod": "^3.25.76"
}
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"devDependencies": {
"@inertiajs/vue3": "2.0",
"@mdi/js": "^7.4.47",
"@playwright/test": "^1.59.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.3",
"@vitejs/plugin-vue": "^6.0.2",
"autoprefixer": "^10.4.22",
"axios": "^1.13.2",
"laravel-vite-plugin": "^2.0.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.16",
"typescript": "^5.9.3",
"vite": "^7.2.7",
"vue": "^3.3.13",
"vue-tsc": "^3.1.8"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.1.2",
"@guolao/vue-monaco-editor": "^1.6.0",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@internationalized/date": "^3.10.0",
"@tanstack/vue-table": "^8.21.3",
"@unovis/ts": "^1.6.2",
"@unovis/vue": "^1.6.2",
"@vee-validate/zod": "^4.15.1",
"@vuepic/vue-datepicker": "^11.0.3",
"@vueuse/core": "^14.1.0",
"apexcharts": "^4.7.0",
"class-variance-authority": "^0.7.1",
"clean": "^4.0.2",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"lucide-vue-next": "^0.552.0",
"material-design-icons-iconfont": "^6.7.0",
"monaco-editor": "^0.55.1",
"preline": "^2.7.0",
"quill": "^1.3.7",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-inner-border": "^0.2.0",
"v-calendar": "^3.1.2",
"vee-validate": "^4.15.1",
"vue-currency-input": "^3.2.1",
"vue-multiselect": "^3.4.0",
"vue-search-input": "^1.1.19",
"vue-sonner": "^2.0.9",
"vue3-apexcharts": "^1.10.0",
"vuedraggable": "^4.1.0",
"zod": "^3.25.76"
}
}
-3
View File
@@ -11,9 +11,6 @@
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<testsuite name="Pure">
<directory>tests/Pure</directory>
</testsuite>
</testsuites>
<source>
<include>
+81
View File
@@ -0,0 +1,81 @@
// @ts-check
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
@@ -11,7 +11,7 @@ import {
} from "@tanstack/vue-table";
import { valueUpdater } from "@/lib/utils";
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
import DataTablePaginationClient from "./DataTablePaginationClient.vue";
import DataTablePagination from "./DataTablePagination.vue";
import DataTableViewOptions from "./DataTableViewOptions.vue";
import DataTableToolbar from "./DataTableToolbar.vue";
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
@@ -618,14 +618,7 @@ defineExpose({
<!-- Client-side pagination -->
<template v-else>
<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"
/>
<DataTablePagination :table="table" />
</template>
</div>
</div>
@@ -23,7 +23,6 @@ const props = defineProps({
showGoto: { type: Boolean, default: true },
maxPageLinks: { type: Number, default: 5 },
perPage: { type: Number, default: 10 },
table: { type: Object, required: true },
});
const emit = defineEmits(["update:page"]);
@@ -35,7 +34,7 @@ function goToPageInput() {
const n = Number(raw);
if (!Number.isFinite(n)) return;
const target = Math.max(1, Math.min(props.lastPage, Math.floor(n)));
if (target !== props.currentPage) props.table.setPageIndex(target - 1);
if (target !== props.currentPage) setPage(target);
gotoInput.value = "";
}
@@ -137,17 +136,14 @@ function setPage(p) {
>
<PaginationContent>
<!-- First -->
<PaginationFirst
:disabled="!table.getCanPreviousPage()"
@click="table.setPageIndex(0)"
>
<PaginationFirst :disabled="currentPage <= 1" @click="setPage(1)">
<ChevronsLeft />
</PaginationFirst>
<!-- Previous -->
<PaginationPrevious
:disabled="!table.getCanPreviousPage()"
@click="table.previousPage()"
:disabled="currentPage <= 1"
@click="setPage(currentPage - 1)"
>
<ChevronLeft />
</PaginationPrevious>
@@ -158,22 +154,25 @@ function setPage(p) {
<PaginationItem
v-else
:value="item"
:is-active="currentPage === index"
@click="table.setPageIndex(index)"
:is-active="currentPage === item"
@click="setPage(item)"
>
{{ item }}
</PaginationItem>
</template>
<!-- Next -->
<PaginationNext :disabled="!table.getCanNextPage()" @click="table.nextPage()">
<PaginationNext
:disabled="currentPage >= lastPage"
@click="setPage(currentPage + 1)"
>
<ChevronRight />
</PaginationNext>
<!-- Last -->
<PaginationLast
:disabled="!table.getCanNextPage()"
@click="table.setPageIndex(table.getPageCount() - 1)"
:disabled="currentPage >= lastPage"
@click="setPage(lastPage)"
>
<ChevronsRight />
</PaginationLast>
@@ -192,7 +191,7 @@ function setPage(p) {
:max="lastPage"
inputmode="numeric"
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
:placeholder="String(currentPage + 1)"
:placeholder="String(currentPage)"
aria-label="Pojdi na stran"
@keyup.enter="goToPageInput"
@blur="goToPageInput"
+8 -5
View File
@@ -2,7 +2,11 @@
import { computed, ref, useAttrs } from "vue";
import { Button } from "@/Components/ui/button";
import { Calendar } from "@/Components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/Components/ui/popover";
import { cn } from "@/lib/utils";
import { CalendarIcon } from "lucide-vue-next";
import { format } from "date-fns";
@@ -82,9 +86,7 @@ const toCalendarDate = (value) => {
// Convert CalendarDate to ISO string (YYYY-MM-DD)
const fromCalendarDate = (calendarDate) => {
if (!calendarDate) return null;
return `${String(calendarDate.year).padStart(4, "0")}-${String(
calendarDate.month
).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
return `${String(calendarDate.year).padStart(4, "0")}-${String(calendarDate.month).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
};
const calendarDate = computed({
@@ -140,10 +142,11 @@ const open = ref(false);
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<Calendar locale="si-SI" v-model="calendarDate" :disabled="disabled" />
<Calendar v-model="calendarDate" :disabled="disabled" />
</PopoverContent>
</Popover>
<p v-if="error" class="mt-1 text-sm text-red-600">
{{ Array.isArray(error) ? error[0] : error }}
</p>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, watch, onUnmounted } from "vue";
import { ref, computed, watch } from "vue";
import {
Dialog,
DialogContent,
@@ -9,7 +9,7 @@ import {
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Badge } from "../ui/badge";
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
import { Loader2 } from "lucide-vue-next";
import axios from "axios";
const props = defineProps({
@@ -26,141 +26,6 @@ const loading = ref(false);
const previewGenerating = ref(false);
const previewError = ref("");
// Image viewer zoom & pan state
const containerRef = ref(null);
const imageRef = ref(null);
const imageScale = ref(1);
const translateX = ref(0);
const translateY = ref(0);
const fitScale = ref(1);
const isDragging = ref(false);
const hasMoved = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const dragStartTX = ref(0);
const dragStartTY = ref(0);
const MAX_SCALE = 8;
const imageCursorClass = computed(() => {
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
return "cursor-default";
});
const initImageView = () => {
const container = containerRef.value;
const img = imageRef.value;
if (!container || !img) return;
const cW = container.clientWidth;
const cH = container.clientHeight;
const iW = img.naturalWidth || cW;
const iH = img.naturalHeight || cH;
const fs = Math.min(1, cW / iW, cH / iH);
fitScale.value = fs;
imageScale.value = fs;
translateX.value = (cW - iW * fs) / 2;
translateY.value = (cH - iH * fs) / 2;
};
const resetImageView = () => {
initImageView();
};
const clampTranslate = (tx, ty, scale) => {
const container = containerRef.value;
const img = imageRef.value;
if (!container || !img) return { tx, ty };
const cW = container.clientWidth;
const cH = container.clientHeight;
const iW = img.naturalWidth * scale;
const iH = img.naturalHeight * scale;
// When image fills the container: clamp so image edges stay within container.
// When image is smaller than container: keep it centered.
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
return {
tx: Math.min(maxX, Math.max(minX, tx)),
ty: Math.min(maxY, Math.max(minY, ty)),
};
};
const zoomAt = (mx, my, factor) => {
const img = imageRef.value;
const iW = img?.naturalWidth ?? 1;
const iH = img?.naturalHeight ?? 1;
const raw = imageScale.value * factor;
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
if (newScale === imageScale.value) return;
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
const clamped = clampTranslate(tx, ty, newScale);
translateX.value = clamped.tx;
translateY.value = clamped.ty;
imageScale.value = newScale;
};
const mousePos = (e) => {
const rect = containerRef.value.getBoundingClientRect();
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
};
const handleImageLoad = () => {
initImageView();
};
const handleWheel = (e) => {
e.preventDefault();
const { mx, my } = mousePos(e);
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
};
const onMouseMove = (e) => {
if (!isDragging.value) return;
const dx = e.clientX - dragStartX.value;
const dy = e.clientY - dragStartY.value;
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
hasMoved.value = true;
}
if (hasMoved.value) {
const clamped = clampTranslate(
dragStartTX.value + dx,
dragStartTY.value + dy,
imageScale.value
);
translateX.value = clamped.tx;
translateY.value = clamped.ty;
}
};
const onMouseUp = () => {
isDragging.value = false;
setTimeout(() => {
hasMoved.value = false;
}, 0);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
const handleMouseDown = (e) => {
if (e.button !== 0) return;
isDragging.value = true;
hasMoved.value = false;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
dragStartTX.value = translateX.value;
dragStartTY.value = translateY.value;
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
onUnmounted(() => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
});
const fileExtension = computed(() => {
if (props.filename) {
return props.filename.split(".").pop()?.toLowerCase() || "";
@@ -253,10 +118,6 @@ watch(
previewGenerating.value = false;
previewError.value = "";
docxPreviewUrl.value = "";
imageScale.value = 1;
translateX.value = 0;
translateY.value = 0;
fitScale.value = 1;
}
},
{ immediate: true }
@@ -318,51 +179,11 @@ watch(
<!-- Image Viewer -->
<template v-else-if="viewerType === 'image' && props.src">
<div
ref="containerRef"
class="relative h-full overflow-hidden select-none"
:class="imageCursorClass"
@mousedown="handleMouseDown"
@wheel.prevent="handleWheel"
>
<img
ref="imageRef"
:src="props.src"
:alt="props.title"
draggable="false"
class="absolute top-0 left-0 max-w-none"
:style="{
transformOrigin: '0 0',
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
transition: isDragging ? 'none' : 'transform 0.12s ease',
}"
@load="handleImageLoad"
/>
<!-- Zoom level badge -->
<div
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
>
{{ Math.round(imageScale * 100) }}%
</div>
<!-- Reset button -->
<Button
v-if="imageScale > fitScale + 0.01"
size="icon-sm"
variant="secondary"
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
title="Ponastavi pogled"
@click.stop="resetImageView"
>
<RotateCcwIcon class="h-3 w-3" />
</Button>
<!-- Hint -->
<div
v-if="imageScale <= fitScale + 0.01"
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
>
Kolesce za povečavo / pomanjšavo · Povleči za premik
</div>
</div>
<img
:src="props.src"
:alt="props.title"
class="max-w-full max-h-full mx-auto object-contain"
/>
</template>
<!-- Text/CSV/XML Viewer -->
@@ -3,8 +3,7 @@ import { computed, ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router, usePage } from "@inertiajs/vue3";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
@@ -28,24 +27,12 @@ const props = defineProps({
const emit = defineEmits(["close"]);
// Decisions with auto_mail = true from shared Inertia data
const page = usePage();
const decisionOptions = computed(() =>
(page.props.auto_mail_decisions ?? []).map((d) => ({
value: String(d.id),
label: d.name,
}))
);
// Zod schema for form validation
const formSchema = toTypedSchema(
z.object({
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
label: z.string().optional(),
receive_auto_mails: z.boolean().optional(),
valid: z.boolean().default(true),
failed: z.boolean().default(false),
decision_ids: z.array(z.string()).optional().default([]),
})
);
@@ -56,15 +43,9 @@ const form = useForm({
value: "",
label: "",
receive_auto_mails: false,
valid: true,
failed: false,
decision_ids: [],
},
});
// Whether to limit sending to specific decisions (UI-only toggle)
const limitToDecisions = ref(false);
const processing = ref(false);
const close = () => {
@@ -76,46 +57,22 @@ const close = () => {
};
const resetForm = () => {
limitToDecisions.value = false;
form.resetForm({
values: {
value: "",
label: "",
receive_auto_mails: false,
valid: true,
failed: false,
decision_ids: [],
},
});
};
// When auto mails is disabled, collapse the decision filter
watch(
() => form.values.receive_auto_mails,
(val) => {
if (!val) {
limitToDecisions.value = false;
}
}
);
// When limit toggle is turned off, clear the selection
watch(limitToDecisions, (val) => {
if (!val) {
form.setFieldValue("decision_ids", []);
}
});
const create = async () => {
processing.value = true;
const payload = {
...form.values,
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
};
const { values } = form;
router.post(
route("person.email.create", props.person),
payload,
values,
{
preserveScroll: true,
onSuccess: () => {
@@ -141,14 +98,11 @@ const create = async () => {
const update = async () => {
processing.value = true;
const payload = {
...form.values,
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
};
const { values } = form;
router.put(
route("person.email.update", { person: props.person, email_id: props.id }),
payload,
values,
{
preserveScroll: true,
onSuccess: () => {
@@ -182,15 +136,10 @@ watch(
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
const email = list.find((e) => e.id === props.id);
if (email) {
const existingDecisionIds = (email.preferences?.decision_ids ?? []).map(String);
limitToDecisions.value = existingDecisionIds.length > 0;
form.setValues({
value: email.value ?? email.email ?? email.address ?? "",
label: email.label ?? "",
receive_auto_mails: !!email.receive_auto_mails,
valid: email.valid !== undefined ? !!email.valid : true,
failed: !!email.failed,
decision_ids: existingDecisionIds,
});
} else {
resetForm();
@@ -279,58 +228,6 @@ const onConfirm = () => {
</div>
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="valid">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Veljavna</FormLabel>
</div>
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="failed">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Neuspešna dostava</FormLabel>
</div>
</FormItem>
</FormField>
<!-- Limit to specific decisions only shown when receive_auto_mails is on and decisions exist -->
<template v-if="(props.person?.client || isClientContext) && form.values.receive_auto_mails && decisionOptions.length > 0">
<div class="flex flex-row items-start space-x-3 space-y-0">
<Switch
:model-value="limitToDecisions"
@update:model-value="(val) => (limitToDecisions = val)"
/>
<div class="space-y-1 leading-none">
<label class="text-sm font-medium leading-none cursor-pointer" @click="limitToDecisions = !limitToDecisions">
Omeji na posamezne odločitve
</label>
</div>
</div>
<FormField v-if="limitToDecisions" v-slot="{ value, handleChange }" name="decision_ids">
<FormItem>
<FormLabel>Odločitve, za katere se pošlje e-pošta</FormLabel>
<FormControl>
<AppMultiSelect
:model-value="value ?? []"
:items="decisionOptions"
placeholder="Izberi odločitve..."
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</template>
</div>
</form>
</component>
@@ -1,483 +0,0 @@
<script setup>
import { ref, watch, computed, nextTick } from "vue";
import axios from "axios";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { router, usePage } from "@inertiajs/vue3";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Button } from "@/Components/ui/button";
import { ScrollArea } from "@/Components/ui/scroll-area";
const props = defineProps({
show: { type: Boolean, default: false },
email: { type: Object, default: null },
clientCaseUuid: { type: String, default: null },
emailTemplates: { type: Array, default: () => [] },
mailProfiles: { type: Array, default: () => [] },
});
const emit = defineEmits(["close"]);
const page = usePage();
const pageProps = computed(() => page?.props ?? {});
const pageEmailTemplates = computed(() => {
const fromProps =
Array.isArray(props.emailTemplates) && props.emailTemplates.length
? props.emailTemplates
: null;
return fromProps ?? pageProps.value?.email_templates ?? [];
});
const pageMailProfiles = computed(() => {
const fromProps =
Array.isArray(props.mailProfiles) && props.mailProfiles.length
? props.mailProfiles
: null;
return fromProps ?? pageProps.value?.mail_profiles ?? [];
});
// Form schema
const formSchema = toTypedSchema(
z.object({
subject: z.string().min(1, "Zadeva je obvezna.").max(255),
html_body: z.string().nullable().optional(),
body_text: z.string().max(10000).nullable().optional(),
template_id: z.number().nullable().optional(),
mail_profile_id: z.number().nullable().optional(),
contract_uuid: z.string().nullable().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
subject: "",
html_body: "",
body_text: "",
template_id: null,
mail_profile_id: null,
contract_uuid: null,
},
});
const processing = ref(false);
const contractsForCase = ref([]);
const hasBodyText = ref(false); // whether selected template uses {{body_text}}
// WYSIWYG iframe
const iframeRef = ref(null);
let iframeSyncing = false;
function ensureFullDoc(html) {
if (!html) {
return '<!doctype html><html><head><meta charset="utf-8" /></head><body></body></html>';
}
if (/<html[\s\S]*<\/html>/i.test(html)) return html;
return `<!doctype html><html><head><meta charset="utf-8" /></head><body>${html}</body></html>`;
}
function writeIframeDocument(html) {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
const full = ensureFullDoc(html ?? form.values.html_body ?? "");
doc.open();
doc.write(full);
doc.close();
try {
doc.body.setAttribute("spellcheck", "false");
} catch {}
}
function initIframeEditor(html) {
writeIframeDocument(html);
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.designMode = "on";
} catch {}
const syncHandler = () => {
if (iframeSyncing) return;
try {
iframeSyncing = true;
const full = doc.documentElement.outerHTML;
form.setFieldValue("html_body", full);
} finally {
iframeSyncing = false;
}
};
doc.removeEventListener("input", syncHandler);
doc.removeEventListener("keyup", syncHandler);
doc.addEventListener("input", syncHandler);
doc.addEventListener("keyup", syncHandler);
}
function iframeExec(command) {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.body.focus();
} catch {}
try {
doc.execCommand(command, false, null);
} catch (e) {
console.warn("execCommand failed", command, e);
}
}
// Load template preview from server
const loadingPreview = ref(false);
const updateFromTemplate = async () => {
if (!form.values.template_id || !props.clientCaseUuid) return;
loadingPreview.value = true;
try {
const url = route("clientCase.email.preview", {
client_case: props.clientCaseUuid,
email_id: props.email?.id,
});
const { data } = await axios.post(url, {
template_id: form.values.template_id,
contract_uuid: form.values.contract_uuid || null,
body_text: form.values.body_text || "",
});
const hadBodyText = hasBodyText.value;
hasBodyText.value = !!data?.has_body_text;
// Pre-fill body_text from text_template when the placeholder is present and field is empty
if (data?.has_body_text && !hadBodyText) {
const tpl = pageEmailTemplates.value.find((t) => t.id === form.values.template_id);
if (tpl?.text_template && !form.values.body_text) {
form.setFieldValue("body_text", tpl.text_template);
}
}
if (data?.subject) {
form.setFieldValue("subject", data.subject);
}
const html = data?.html ?? "";
form.setFieldValue("html_body", html);
await nextTick();
initIframeEditor(html);
} catch (e) {
// ignore
} finally {
loadingPreview.value = false;
}
};
watch(
() => form.values.template_id,
() => {
updateFromTemplate();
}
);
watch(
() => form.values.contract_uuid,
() => {
if (form.values.template_id) {
updateFromTemplate();
}
}
);
// Re-preview when body_text changes (debounce-like: only when a template is active)
watch(
() => form.values.body_text,
() => {
if (form.values.template_id && hasBodyText.value) {
updateFromTemplate();
}
}
);
const loadContractsForCase = async () => {
try {
const url = route("clientCase.contracts.list", {
client_case: props.clientCaseUuid,
});
const res = await fetch(url, {
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin",
});
const json = await res.json();
contractsForCase.value = Array.isArray(json?.data) ? json.data : [];
} catch (e) {
contractsForCase.value = [];
}
};
watch(
() => props.show,
async (newVal) => {
if (newVal) {
form.resetForm({
values: {
subject: "",
html_body: "",
body_text: "",
template_id: null,
mail_profile_id: pageMailProfiles.value?.[0]?.id ?? null,
contract_uuid: null,
},
});
hasBodyText.value = false;
contractsForCase.value = [];
await loadContractsForCase();
// Init empty iframe
await nextTick();
initIframeEditor("");
}
}
);
const closeDialog = () => {
emit("close");
};
const onSubmit = form.handleSubmit((values) => {
if (!props.email || !props.clientCaseUuid) return;
processing.value = true;
router.post(
route("clientCase.email.send", {
client_case: props.clientCaseUuid,
email_id: props.email.id,
}),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
closeDialog();
},
onError: () => {
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
});
const open = computed({
get: () => props.show,
set: (value) => {
if (!value) closeDialog();
},
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Pošlji e-pošto</DialogTitle>
<DialogDescription>
<p class="text-sm text-gray-600">
Prejemnik:
<span class="font-mono">{{ email?.value || email?.email || email?.address }}</span>
</p>
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[70vh] pr-1">
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
<!-- Mail profile -->
<FormField v-slot="{ value, handleChange }" name="mail_profile_id">
<FormItem>
<FormLabel>E-poštni profil</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="p in pageMailProfiles"
:key="p.id"
:value="p.id"
>
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<!-- Contract -->
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
<FormItem>
<FormLabel>Pogodba</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="c in contractsForCase"
:key="c.uuid"
:value="c.uuid"
>
{{ c.reference || c.uuid }}
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-gray-500">
Izberite pogodbo za zapolnitev spremenljivk v predlogi.
</p>
<FormMessage />
</FormItem>
</FormField>
<!-- Template -->
<FormField v-slot="{ value, handleChange }" name="template_id">
<FormItem>
<FormLabel>Predloga</FormLabel>
<Select
:model-value="value"
@update:model-value="handleChange"
:disabled="loadingPreview"
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="t in pageEmailTemplates"
:key="t.id"
:value="t.id"
>
{{ t.name || "Predloga #" + t.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<!-- Subject -->
<FormField v-slot="{ componentField }" name="subject">
<FormItem>
<FormLabel>Zadeva</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Zadeva e-poštnega sporočila..."
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- body_text textarea shown only when the template uses {{body_text}} -->
<FormField v-if="hasBodyText" v-slot="{ componentField }" name="body_text">
<FormItem>
<FormLabel>Besedilo sporočila</FormLabel>
<FormControl>
<Textarea
placeholder="Vnesite besedilo, ki se vstavi na mesto {{body_text}} v predlogi..."
class="min-h-[120px] resize-y"
v-bind="componentField"
/>
</FormControl>
<p class="mt-1 text-xs text-gray-500">
Besedilo se vstavi na oznako <code>&#123;&#123;body_text&#125;&#125;</code> v predlogi. Besedilo ne podpira spremenljivk.
</p>
<FormMessage />
</FormItem>
</FormField>
<!-- WYSIWYG body editor -->
<div>
<label class="text-sm font-medium leading-none">Vsebina</label>
<!-- Toolbar -->
<div class="flex gap-1 mt-2 mb-1 border rounded-t-md bg-gray-50 p-1">
<Button
type="button"
size="sm"
variant="ghost"
class="font-bold px-2 py-1 h-7"
title="Krepko (Ctrl+B)"
@click="iframeExec('bold')"
>B</Button>
<Button
type="button"
size="sm"
variant="ghost"
class="italic px-2 py-1 h-7"
title="Poševno (Ctrl+I)"
@click="iframeExec('italic')"
>I</Button>
<Button
type="button"
size="sm"
variant="ghost"
class="underline px-2 py-1 h-7"
title="Podčrtano (Ctrl+U)"
@click="iframeExec('underline')"
>U</Button>
</div>
<iframe
ref="iframeRef"
class="w-full border rounded-b-md bg-white"
style="min-height: 240px; max-height: 360px"
frameborder="0"
sandbox="allow-same-origin allow-scripts"
/>
<p class="mt-1 text-xs text-gray-500">
Kliknite v vsebino in začnite pisati. Izberite predlogo za samodejno zapolnitev.
</p>
</div>
</form>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="closeDialog" :disabled="processing">
Prekliči
</Button>
<Button
@click="onSubmit"
:disabled="processing || !form.values.subject"
>
{{ processing ? "Pošiljanje..." : "Pošlji" }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -8,16 +8,14 @@ import {
} from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card";
import { Button } from "../ui/button";
import { CircleCheckBigIcon, CircleXIcon, EllipsisVertical, MailIcon } from "lucide-vue-next";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
import { EllipsisVertical } from "lucide-vue-next";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
enableEmail: { type: Boolean, default: false },
});
const emit = defineEmits(["add", "edit", "delete", "email"]);
const emit = defineEmits(["add", "edit", "delete"]);
const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []);
@@ -46,17 +44,7 @@ const handleDelete = (id, label) => emit("delete", id, label);
</span>
</div>
<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>
<Button variant="ghost" size="icon" title="Možnosti">
<EllipsisVertical />
@@ -78,28 +66,11 @@ const handleDelete = (id, label) => emit("delete", id, label);
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div class="p-1">
<p class="font-medium text-gray-900 leading-relaxed flex gap-1 items-center">
<p class="font-medium text-gray-900 leading-relaxed">
{{ email?.value || email?.email || email?.address || "-" }}
<TooltipProvider v-if="email?.valid">
<Tooltip>
<TooltipTrigger as-child>
<CircleCheckBigIcon color="#3e9392" :size="18" />
</TooltipTrigger>
<TooltipContent>Veljavna</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider v-if="email?.failed">
<Tooltip>
<TooltipTrigger as-child>
<CircleXIcon color="#dc2626" :size="18" />
</TooltipTrigger>
<TooltipContent>Neuspešna dostava</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
<p
v-if="email?.note"
@@ -30,7 +30,6 @@ import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
import PersonInfoEmailDialog from "./PersonInfoEmailDialog.vue";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
@@ -59,9 +58,6 @@ const props = defineProps({
smsProfiles: { type: Array, default: () => [] },
smsSenders: { type: Array, default: () => [] },
smsTemplates: { type: Array, default: () => [] },
enableEmail: { type: Boolean, default: false },
emailTemplates: { type: Array, default: () => [] },
mailProfiles: { type: Array, default: () => [] },
});
// Dialog states
@@ -95,10 +91,6 @@ const confirm = ref({
const showSmsDialog = ref(false);
const smsTargetPhone = ref(null);
// Email dialog state
const showEmailDialog = ref(false);
const emailTarget = ref(null);
// Person handlers
const openDrawerUpdateClient = () => {
drawerUpdatePerson.value = true;
@@ -259,18 +251,6 @@ const closeSmsDialog = () => {
smsTargetPhone.value = null;
};
// Email dialog handlers
const openEmailDialog = (email) => {
if (!props.enableEmail || !props.clientCaseUuid) return;
emailTarget.value = email;
showEmailDialog.value = true;
};
const closeEmailDialog = () => {
showEmailDialog.value = false;
emailTarget.value = null;
};
// Tab event handlers
const handlePersonEdit = () => openDrawerUpdateClient();
@@ -286,7 +266,6 @@ const handlePhoneSms = (phone) => openSmsDialog(phone);
const handleEmailAdd = () => openDrawerAddEmail(false, 0);
const handleEmailEdit = (id) => openDrawerAddEmail(true, id);
const handleEmailDelete = (id, label) => openConfirm("email", id, label);
const handleEmailSend = (email) => openEmailDialog(email);
const handleTrrAdd = () => openDrawerAddTrr(false, 0);
const handleTrrEdit = (id) => openDrawerAddTrr(true, id);
@@ -439,11 +418,9 @@ const switchToTab = (tab) => {
<PersonInfoEmailsTab
:person="person"
:edit="edit"
:enable-email="enableEmail && !!clientCaseUuid"
@add="handleEmailAdd"
@edit="handleEmailEdit"
@delete="handleEmailDelete"
@email="handleEmailSend"
/>
</TabsContent>
@@ -557,15 +534,4 @@ const switchToTab = (tab) => {
:sms-templates="smsTemplates"
@close="closeSmsDialog"
/>
<!-- Email Dialog -->
<PersonInfoEmailDialog
v-if="clientCaseUuid"
:show="showEmailDialog"
:email="emailTarget"
:client-case-uuid="clientCaseUuid"
:email-templates="emailTemplates"
:mail-profiles="mailProfiles"
@close="closeEmailDialog"
/>
</template>
@@ -25,7 +25,6 @@ import {
} from "@/Components/ui/select";
import { Switch } from "@/Components/ui/switch";
import { Button } from "@/Components/ui/button";
import { ScrollArea } from "@/Components/ui/scroll-area";
const props = defineProps({
show: { type: Boolean, default: false },
@@ -453,57 +452,11 @@ const open = computed({
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[65vh] pr-1">
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField v-slot="{ value, handleChange }" name="profile_id">
<FormItem>
<FormLabel>Profil</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="p in pageSmsProfiles" :key="p.id" :value="p.id">
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="sender_id">
<FormItem>
<FormLabel>Pošiljatelj</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="s in sendersForSelectedProfile"
:key="s.id"
:value="s.id"
>
{{ s.name || s.phone || "Sender #" + s.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
<form @submit.prevent="onSubmit" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField v-slot="{ value, handleChange }" name="profile_id">
<FormItem>
<FormLabel>Pogodba</FormLabel>
<FormLabel>Profil</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
@@ -512,22 +465,18 @@ const open = computed({
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="c in contractsForCase" :key="c.uuid" :value="c.uuid">
{{ c.reference || c.uuid }}
<SelectItem v-for="p in pageSmsProfiles" :key="p.id" :value="p.id">
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in
{account.*} mest.
</p>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="template_id">
<FormField v-slot="{ value, handleChange }" name="sender_id">
<FormItem>
<FormLabel>Predloga</FormLabel>
<FormLabel>Pošiljatelj</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
@@ -536,77 +485,125 @@ const open = computed({
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
{{ t.name || "Predloga #" + t.id }}
<SelectItem
v-for="s in sendersForSelectedProfile"
:key="s.id"
:value="s.id"
>
{{ s.name || s.phone || "Sender #" + s.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>Vsebina sporočila</FormLabel>
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
<FormItem>
<FormLabel>Pogodba</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<Textarea
rows="4"
placeholder="Vpišite SMS vsebino..."
v-bind="componentField"
/>
<SelectTrigger>
<SelectValue placeholder="" />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Live counters -->
<div class="text-xs text-gray-600 flex flex-col gap-1">
<div>
<span class="font-medium">Znakov:</span>
<span class="font-mono">{{ charCount }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Kodiranje:</span>
<span>{{ smsEncoding }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Deli SMS:</span>
<span class="font-mono">{{ segments }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Krediti:</span>
<span class="font-mono">{{ creditsNeeded }}</span>
</div>
<div>
<span class="font-medium">Omejitev:</span>
<span class="font-mono">{{ maxAllowed }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Preostanek:</span>
<span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">
{{ remaining }}
</span>
</div>
<p class="text-[11px] text-gray-500 leading-snug">
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake,
ki ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS2). V tem
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in )
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM7) oziroma
320 (UCS2) znakov.
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="c in contractsForCase" :key="c.uuid" :value="c.uuid">
{{ c.reference || c.uuid }}
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in
{account.*} mest.
</p>
</div>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="delivery_report">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormField v-slot="{ value, handleChange }" name="template_id">
<FormItem>
<FormLabel>Predloga</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>Zahtevaj poročilo o dostavi</FormLabel>
</div>
</FormItem>
</FormField>
</form>
</ScrollArea>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
{{ t.name || "Predloga #" + t.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>Vsebina sporočila</FormLabel>
<FormControl>
<Textarea
rows="4"
placeholder="Vpišite SMS vsebino..."
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Live counters -->
<div class="text-xs text-gray-600 flex flex-col gap-1">
<div>
<span class="font-medium">Znakov:</span>
<span class="font-mono">{{ charCount }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Kodiranje:</span>
<span>{{ smsEncoding }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Deli SMS:</span>
<span class="font-mono">{{ segments }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Krediti:</span>
<span class="font-mono">{{ creditsNeeded }}</span>
</div>
<div>
<span class="font-medium">Omejitev:</span>
<span class="font-mono">{{ maxAllowed }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Preostanek:</span>
<span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">
{{ remaining }}
</span>
</div>
<p class="text-[11px] text-gray-500 leading-snug">
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake, ki
ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS2). V tem
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in )
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM7) oziroma
320 (UCS2) znakov.
</p>
</div>
<FormField v-slot="{ value, handleChange }" name="delivery_report">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>Zahtevaj poročilo o dostavi</FormLabel>
</div>
</FormItem>
</FormField>
</form>
<DialogFooter>
<Button variant="outline" @click="closeSmsDialog" :disabled="processing">
@@ -84,8 +84,8 @@ const summaryText = computed(() => {
const found = props.items.find((i) => String(i.value) === String(v));
return found?.label || v;
});
if (labels.length <= 3) return labels.join(", ");
const firstThree = labels.slice(0, 3).join(", ");
if (labels.length <= 3) return labels.join(', ');
const firstThree = labels.slice(0, 3).join(', ');
const remaining = labels.length - 3;
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
});
@@ -154,7 +154,7 @@ const summaryText = computed(() => {
:variant="chipVariant"
class="flex items-center gap-1"
>
<span class="truncate max-w-35">
<span class="truncate max-w-[140px]">
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
</span>
<button
@@ -26,7 +26,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DropdownMenuPortal>
<DropdownMenuContent
v-bind="forwarded"
: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)"
: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)"
>
<slot />
</DropdownMenuContent>
@@ -1,97 +0,0 @@
import { ref, onMounted, onUnmounted } from "vue";
import { router } from "@inertiajs/vue3";
/**
* Composable for infinite scroll with Inertia v2.
*
* @param {Function} getProp - () => the current paginator object from Inertia props
* @param {string} propName - the prop key name to reload
* @param {string} pageParam - query string parameter name for page number
* @param {Function} getRouteUrl - () => current URL to reload
*/
export function useInfiniteList(getProp, propName, pageParam, getRouteUrl) {
const items = ref([]);
const currentPage = ref(1);
const lastPage = ref(1);
const isLoadingMore = ref(false);
const sentinelRef = ref(null);
let observer = null;
function syncFromProp() {
const prop = getProp();
if (!prop) return;
lastPage.value = prop.last_page ?? 1;
}
function appendFromProp() {
const prop = getProp();
if (!prop?.data) return;
// append only new items (avoid duplicates by id)
const existingIds = new Set(items.value.map((i) => i.id));
const newItems = prop.data.filter((i) => !existingIds.has(i.id));
items.value.push(...newItems);
}
function reset(initialProp) {
items.value = initialProp?.data ?? [];
currentPage.value = initialProp?.current_page ?? 1;
lastPage.value = initialProp?.last_page ?? 1;
}
function loadMore() {
if (isLoadingMore.value) return;
if (currentPage.value >= lastPage.value) return;
const nextPage = currentPage.value + 1;
isLoadingMore.value = true;
const params = new URLSearchParams(window.location.search);
params.set(pageParam, nextPage);
router.reload({
url: `${window.location.pathname}?${params.toString()}`,
only: [propName],
preserveScroll: true,
preserveState: true,
onSuccess: () => {
appendFromProp();
currentPage.value = nextPage;
isLoadingMore.value = false;
},
onError: () => {
isLoadingMore.value = false;
},
});
}
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ rootMargin: "200px" }
);
if (sentinelRef.value) {
observer.observe(sentinelRef.value);
}
});
onUnmounted(() => {
observer?.disconnect();
});
return {
items,
currentPage,
lastPage,
isLoadingMore,
sentinelRef,
reset,
syncFromProp,
appendFromProp,
loadMore,
};
}
+8
View File
@@ -15,6 +15,7 @@ import {
InboxIcon,
AtSignIcon,
BookUserIcon,
MessageSquareIcon,
ArrowLeftIcon,
} from "lucide-vue-next";
import Dropdown from "@/Components/Dropdown.vue";
@@ -210,6 +211,13 @@ const navGroups = computed(() => [
icon: Settings2Icon,
active: ["admin.sms-profiles.index"],
},
{
key: "admin.packages.index",
label: "SMS paketi",
route: "admin.packages.index",
icon: MessageSquareIcon,
active: ["admin.packages.index", "admin.packages.show"],
},
],
},
]);
+1 -33
View File
@@ -26,9 +26,6 @@ import { SettingsIcon } from "lucide-vue-next";
import { ShieldUserIcon } from "lucide-vue-next";
import { SmartphoneIcon } from "lucide-vue-next";
import { TabletSmartphoneIcon } from "lucide-vue-next";
import { PhoneCallIcon } from "lucide-vue-next";
import { PackageIcon } from "lucide-vue-next";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
title: String,
@@ -160,13 +157,6 @@ const rawMenuGroups = [
routeName: "segments.index",
active: ["segments.index"],
},
{
key: "call-laters",
icon: PhoneCallIcon,
title: "Pokliči kasneje",
routeName: "callLaters.index",
active: ["callLaters.index"],
},
],
},
{
@@ -222,13 +212,6 @@ const rawMenuGroups = [
routeName: "settings",
active: ["settings", "settings.*"],
},
{
key: "packages",
icon: PackageIcon,
title: "Paketno pošiljanje",
routeName: "packages.index",
active: ["packages.index", "packages.show", "packages.create"],
},
// Admin panel (roles & permissions management)
// Only shown if current user has admin role or manage-settings permission.
// We'll filter it out below if not authorized.
@@ -285,14 +268,6 @@ function isActive(patterns) {
return false;
}
}
function getBadge(item) {
if (item.key === "call-laters") {
return page.props.callLaterCount || 0;
}
return 0;
}
</script>
<template>
@@ -366,18 +341,11 @@ function getBadge(item) {
<!-- Title -->
<span
v-if="!sidebarCollapsed"
class="flex-1 truncate transition-opacity"
class="truncate transition-opacity"
:class="{ 'font-medium': isActive(item.active) }"
>
{{ item.title }}
</span>
<Badge
v-if="!sidebarCollapsed && getBadge(item) > 0"
variant="destructive"
class="ml-auto shrink-0 px-1.5 py-0.5 text-xs font-mono text-amber-50"
>
{{ getBadge(item) }}
</Badge>
</Link>
</li>
</ul>
+1 -1
View File
@@ -308,7 +308,7 @@ const closeSearch = () => (searchOpen.value = false);
</div>
<!-- Page Heading -->
<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">
<header v-if="$slots.header" class="bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
<Breadcrumbs
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
@@ -64,7 +64,6 @@ import "quill/dist/quill.snow.css";
const props = defineProps({
template: { type: Object, default: null },
actions: { type: Array, default: () => [] },
});
const form = useForm({
@@ -76,9 +75,6 @@ const form = useForm({
entity_types: props.template?.entity_types ?? ["client", "contract"],
allow_attachments: props.template?.allow_attachments ?? false,
active: props.template?.active ?? true,
client: props.template?.client ?? false,
action_id: props.template?.action_id ?? null,
decision_id: props.template?.decision_id ?? null,
});
const preview = ref({ subject: "", html: "", text: "" });
@@ -736,8 +732,7 @@ const placeholderGroups = computed(() => {
"contract.id",
"contract.uuid",
"contract.reference",
"contract.account.balance_amount",
"contract.account.initial_amount",
"contract.amount",
"contract.meta.some_key",
]);
}
@@ -752,13 +747,6 @@ const placeholderGroups = computed(() => {
]);
// Extra is always useful for ad-hoc data
add("extra", "Extra", ["extra.some_key"]);
// Profile signature tokens (resolved from the active mail profile at send time)
add("profile", "Profil / Podpis", [
"profile.signature.ime",
"profile.signature.naziv",
"profile.signature.telefon",
"profile.signature.email",
]);
return groups;
});
@@ -1040,49 +1028,6 @@ watch(
/>
<Label for="active" class="font-normal cursor-pointer">Aktivno</Label>
</div>
<div class="flex items-center gap-2">
<Switch
id="client"
:default-value="form.client"
@update:model-value="(val) => (form.client = val)"
/>
<Label for="client" class="font-normal cursor-pointer">Samo za stranke</Label>
</div>
</div>
<!-- Activity after send: action + decision -->
<div>
<Label class="mb-2 block">Aktivnost po pošiljanju</Label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="action_id">Akcija</Label>
<Select v-model="form.action_id" @update:model-value="(val) => { form.action_id = val; form.decision_id = null; }">
<SelectTrigger id="action_id">
<SelectValue placeholder="Brez" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Brez</SelectItem>
<SelectItem v-for="a in props.actions" :key="a.id" :value="a.id">{{ a.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="decision_id">Odločitev</Label>
<Select v-model="form.decision_id" :disabled="!form.action_id">
<SelectTrigger id="decision_id">
<SelectValue placeholder="Brez" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Brez</SelectItem>
<SelectItem
v-for="d in props.actions?.find((x) => x.id === form.action_id)?.decisions || []"
:key="d.id"
:value="d.id"
>{{ d.name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
@@ -1278,25 +1223,6 @@ watch(
</Button>
</div>
</div>
<!-- Special tokens -->
<div class="space-y-2">
<div class="text-sm font-medium text-muted-foreground">Posebni žetoni</div>
<p class="text-xs text-muted-foreground">
<code class="font-mono">&#123;&#123; body_text &#125;&#125;</code> pri pošiljanju ga nadomesti besedilo, ki ga vnese pošiljatelj.
</p>
<div class="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
@click="insertPlaceholder('body_text')"
class="font-mono text-xs"
>
<PlusCircleIcon class="h-3 w-3 mr-1" />
body_text
</Button>
</div>
</div>
</div>
</div>
</CardContent>
+6
View File
@@ -107,6 +107,12 @@ const cards = [
route: "admin.sms-logs.index",
icon: InboxIcon,
},
{
title: "SMS paketi",
description: "Kreiranje in pošiljanje serijskih SMS paketov",
route: "admin.packages.index",
icon: MessageSquareIcon,
},
],
},
];
+6 -144
View File
@@ -9,7 +9,6 @@ import {
PencilIcon,
SendIcon,
MoreVerticalIcon,
Trash2Icon,
} from "lucide-vue-next";
import {
Card,
@@ -63,32 +62,6 @@ const createOpen = ref(false); // create modal
const editOpen = ref(false); // edit modal
const editTarget = ref(null); // profile being edited
// Signature items array of {key, value} pairs edited in the dialog
const signatureItems = ref([{ key: "", value: "" }]);
function addSignatureItem() {
signatureItems.value.push({ key: "", value: "" });
}
function removeSignatureItem(index) {
signatureItems.value.splice(index, 1);
if (signatureItems.value.length === 0)
signatureItems.value.push({ key: "", value: "" });
}
function signatureToObject() {
const obj = {};
signatureItems.value.forEach(({ key, value }) => {
const k = (key || "").trim();
if (k) obj[k] = value ?? "";
});
return Object.keys(obj).length ? obj : null;
}
function signatureFromObject(sig) {
const entries = Object.entries(sig || {});
return entries.length
? entries.map(([key, value]) => ({ key, value }))
: [{ key: "", value: "" }];
}
const form = useForm({
name: "",
host: "",
@@ -99,12 +72,10 @@ const form = useForm({
from_address: "",
from_name: "",
priority: 10,
auto_mailer: false,
});
function openCreate() {
form.reset();
signatureItems.value = [{ key: "", value: "" }];
createOpen.value = true;
editTarget.value = null;
}
@@ -121,9 +92,7 @@ function openEdit(p) {
form.from_address = p.from_address || "";
form.from_name = p.from_name || "";
form.priority = p.priority ?? 10;
form.auto_mailer = p.auto_mailer ?? false;
editTarget.value = p;
signatureItems.value = signatureFromObject(p.signature);
editOpen.value = true;
}
@@ -133,14 +102,12 @@ function closeCreate() {
}
function submitCreate() {
form
.transform((data) => ({ ...data, signature: signatureToObject() }))
.post(route("admin.mail-profiles.store"), {
preserveScroll: true,
onSuccess: () => {
createOpen.value = false;
},
});
form.post(route("admin.mail-profiles.store"), {
preserveScroll: true,
onSuccess: () => {
createOpen.value = false;
},
});
}
function closeEdit() {
@@ -161,8 +128,6 @@ function submitEdit() {
from_address: form.from_address,
from_name: form.from_name || null,
priority: form.priority,
auto_mailer: form.auto_mailer,
signature: signatureToObject(),
};
if (form.password && form.password.trim() !== "") {
payload.password = form.password.trim();
@@ -184,12 +149,6 @@ function toggleActive(p) {
.then(() => window.location.reload());
}
function toggleAutoMailer(p) {
window.axios
.post(route("admin.mail-profiles.toggle-auto-mailer", p.id))
.then(() => window.location.reload());
}
function testConnection(p) {
window.axios
.post(route("admin.mail-profiles.test", p.id))
@@ -247,7 +206,6 @@ const statusClass = (p) => {
<TableHead class="text-center">Port</TableHead>
<TableHead class="text-center">Enc</TableHead>
<TableHead class="text-center">Aktivno</TableHead>
<TableHead class="text-center">Auto-mailer</TableHead>
<TableHead class="text-center">Status</TableHead>
<TableHead>Zadnji uspeh</TableHead>
<TableHead>Napaka</TableHead>
@@ -271,12 +229,6 @@ const statusClass = (p) => {
@update:model-value="() => toggleActive(p)"
/>
</TableCell>
<TableCell class="text-center">
<Switch
:default-value="p.auto_mailer"
@update:model-value="() => toggleAutoMailer(p)"
/>
</TableCell>
<TableCell class="text-center">
<Badge
v-if="p.test_status === 'success'"
@@ -398,51 +350,6 @@ const statusClass = (p) => {
<Label for="create-priority">Prioriteta</Label>
<Input id="create-priority" v-model.number="form.priority" type="number" />
</div>
<div class="flex items-center gap-3">
<Switch
id="create-auto-mailer"
:model-value="form.auto_mailer"
@update:model-value="(val) => (form.auto_mailer = val)"
/>
<Label for="create-auto-mailer">Auto-mailer</Label>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Podpis (signature)</Label>
<Button type="button" size="sm" variant="outline" @click="addSignatureItem">
<PlusIcon class="h-3 w-3 mr-1" />
Dodaj vrstico
</Button>
</div>
<p class="text-xs text-muted-foreground">
Vrednosti so dostopne v predlogah kot
<code class="font-mono" v-pre>{{ profile.signature.ključ }}</code
>.
</p>
<div class="space-y-2">
<div
v-for="(item, i) in signatureItems"
:key="i"
class="flex gap-2 items-start"
>
<Input
v-model="item.key"
placeholder="Ključ (npr. ime)"
class="w-36 shrink-0 font-mono text-xs"
/>
<Input v-model="item.value" placeholder="Vrednost" class="flex-1" />
<Button
type="button"
variant="ghost"
size="sm"
@click="removeSignatureItem(i)"
>
<Trash2Icon class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
</form>
<DialogFooter>
@@ -511,51 +418,6 @@ const statusClass = (p) => {
<Label for="edit-priority">Prioriteta</Label>
<Input id="edit-priority" v-model.number="form.priority" type="number" />
</div>
<div class="flex items-center gap-3">
<Switch
id="edit-auto-mailer"
:model-value="form.auto_mailer"
@update:model-value="(val) => (form.auto_mailer = val)"
/>
<Label for="edit-auto-mailer">Auto-mailer</Label>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Podpis (signature)</Label>
<Button type="button" size="sm" variant="outline" @click="addSignatureItem">
<PlusIcon class="h-3 w-3 mr-1" />
Dodaj vrstico
</Button>
</div>
<p class="text-xs text-muted-foreground">
Vrednosti so dostopne v predlogah kot
<code class="font-mono" v-pre>{{ profile.signature.ključ }}</code
>.
</p>
<div class="space-y-2">
<div
v-for="(item, i) in signatureItems"
:key="i"
class="flex gap-2 items-start"
>
<Input
v-model="item.key"
placeholder="Ključ (npr. ime)"
class="w-36 shrink-0 font-mono text-xs"
/>
<Input v-model="item.value" placeholder="Vrednost" class="flex-1" />
<Button
type="button"
variant="ghost"
size="sm"
@click="removeSignatureItem(i)"
>
<Trash2Icon class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
<p class="text-sm text-muted-foreground">
Pusti geslo prazno, če želiš obdržati obstoječe.
@@ -1,5 +1,5 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, router, useForm } from "@inertiajs/vue3";
import { ref, computed, nextTick } from "vue";
import axios from "axios";
@@ -38,8 +38,6 @@ import {
CheckCircle2Icon,
XCircleIcon,
BadgeCheckIcon,
CircleCheckIcon,
BadgeXIcon,
} from "lucide-vue-next";
import { fmtDateDMY } from "@/Utilities/functions";
import { upperFirst } from "lodash";
@@ -114,9 +112,9 @@ function submitCreate() {
})),
};
router.post(route("packages.store"), payload, {
router.post(route("admin.packages.store"), payload, {
onSuccess: () => {
router.visit(route("packages.index"));
router.visit(route("admin.packages.index"));
},
});
}
@@ -204,7 +202,7 @@ async function loadContracts(url = null) {
if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
const target = url || `${route("packages.contracts")}?${params.toString()}`;
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
const { data: json } = await axios.get(target, {
headers: { "X-Requested-With": "XMLHttpRequest" },
});
@@ -270,7 +268,7 @@ function goToPage(page) {
params.append("per_page", perPage.value);
params.append("page", page);
const url = `${route("packages.contracts")}?${params.toString()}`;
const url = `${route("admin.packages.contracts")}?${params.toString()}`;
loadContracts(url);
}
@@ -314,9 +312,9 @@ function submitCreateFromContracts() {
};
creatingFromContracts.value = true;
router.post(route("packages.store-from-contracts"), payload, {
router.post(route("admin.packages.store-from-contracts"), payload, {
onSuccess: () => {
router.visit(route("packages.index"));
router.visit(route("admin.packages.index"));
},
onError: (errors) => {
const first = errors && Object.values(errors)[0];
@@ -339,11 +337,11 @@ const numbersCount = computed(() => {
</script>
<template>
<AppLayout title="Ustvari SMS paket">
<AdminLayout title="Ustvari SMS paket">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<Link :href="route('packages.index')">
<Link :href="route('admin.packages.index')">
<Button variant="ghost" size="sm">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
@@ -521,7 +519,10 @@ const numbersCount = computed(() => {
</div>
</div>
<div class="flex justify-end gap-2">
<Button @click="router.visit(route('packages.index'))" variant="outline">
<Button
@click="router.visit(route('admin.packages.index'))"
variant="outline"
>
Prekliči
</Button>
<Button
@@ -701,7 +702,10 @@ const numbersCount = computed(() => {
<CheckCircle2Icon class="h-3 w-3" />
Izbrano: {{ selectedContractIds.size }}
</Badge>
<Button @click="router.visit(route('packages.index'))" variant="outline">
<Button
@click="router.visit(route('admin.packages.index'))"
variant="outline"
>
Prekliči
</Button>
<Button
@@ -765,26 +769,26 @@ const numbersCount = computed(() => {
</template>
<template #cell-selected_phone="{ row }">
<div v-if="row.selected_phone" class="space-y-1 flex flex-col gap-1">
<div class="flex flex-row gap-1 items-center">
<span>
{{ row.selected_phone.number }}
</span>
<BadgeCheckIcon
size="18"
color="green"
v-if="row.selected_phone.validated"
/>
<BadgeXIcon
size="18"
color="red"
v-if="!row.selected_phone.validated"
/>
</div>
<div v-if="row.selected_phone.description">
<Badge variant="secondary" class="break-all">
{{ row.selected_phone.description }}
</Badge>
<div v-if="row.selected_phone" class="space-y-1">
<div class="flex flex-col items-center gap-1">
<span>{{ row.selected_phone.number }}</span>
<span
><Badge
v-if="row.selected_phone.validated"
variant="secondary"
class="text-xs"
>
<BadgeCheckIcon />
Potrjena
</Badge>
<Badge
v-else
variant="destructive"
class="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums text-accent"
>
Nepotrjena
</Badge></span
>
</div>
</div>
<span v-else class="text-xs text-destructive">Ni telefonske št.</span>
@@ -802,5 +806,5 @@ const numbersCount = computed(() => {
</Card>
</TabsContent>
</Tabs>
</AppLayout>
</AdminLayout>
</template>
@@ -1,5 +1,5 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
@@ -16,7 +16,7 @@ import {
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import { MessageSquareIcon, PlusIcon, Trash2Icon, EyeIcon, ArrowLeftIcon } from "lucide-vue-next";
import { PackageIcon, PlusIcon, Trash2Icon, EyeIcon } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { fmtDateTime } from "@/Utilities/functions";
@@ -31,6 +31,7 @@ const showDeleteDialog = ref(false);
const columns = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "name", header: "Ime" },
{ accessorKey: "type", header: "Tip" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "total_items", header: "Skupaj" },
{ accessorKey: "sent_count", header: "Poslano" },
@@ -47,7 +48,7 @@ function getStatusVariant(status) {
}
function goShow(id) {
router.visit(route("packages.sms.show", id));
router.visit(route("admin.packages.show", id));
}
function openDeleteDialog(pkg) {
@@ -59,7 +60,7 @@ function openDeleteDialog(pkg) {
function confirmDelete() {
if (!packageToDelete.value) return;
deletingId.value = packageToDelete.value.id;
router.delete(route("packages.sms.destroy", packageToDelete.value.id), {
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
onSuccess: () => {
router.reload({ only: ["packages"] });
},
@@ -73,21 +74,15 @@ function confirmDelete() {
</script>
<template>
<AppLayout title="SMS paketi">
<AdminLayout title="SMS paketi">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" as-child>
<Link :href="route('packages.index')">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Paketi
</Link>
</Button>
<MessageSquareIcon class="h-5 w-5 text-muted-foreground" />
<PackageIcon class="h-5 w-5 text-muted-foreground" />
<CardTitle>SMS paketi</CardTitle>
</div>
<Link :href="route('packages.sms.create')">
<Link :href="route('admin.packages.create')">
<Button>
<PlusIcon class="h-4 w-4" />
Nov paket
@@ -106,20 +101,24 @@ function confirmDelete() {
>
<template #header>
<div class="flex items-center gap-2">
<MessageSquareIcon size="18" />
<CardTitle class="uppercase">SMS Paketi</CardTitle>
<PackageIcon size="18" />
<CardTitle class="uppercase">Paketi</CardTitle>
</div>
</template>
<DataTableNew2
:columns="columns"
:data="packages.data"
:meta="packages"
route-name="packages.sms.index"
route-name="admin.packages.index"
>
<template #cell-name="{ row }">
<span class="text-sm">{{ row.name ?? "—" }}</span>
</template>
<template #cell-type="{ row }">
<Badge variant="outline" class="uppercase">{{ row.type }}</Badge>
</template>
<template #cell-status="{ row }">
<Badge :variant="getStatusVariant(row.status)">{{ row.status }}</Badge>
</template>
@@ -173,5 +172,5 @@ function confirmDelete() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AppLayout>
</AdminLayout>
</template>
@@ -1,5 +1,5 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { onMounted, onUnmounted, ref, computed } from "vue";
import {
@@ -88,14 +88,14 @@ function reload() {
function dispatchPkg() {
router.post(
route("packages.dispatch", props.package.id),
route("admin.packages.dispatch", props.package.id),
{},
{ onSuccess: reload }
);
}
function cancelPkg() {
router.post(
route("packages.cancel", props.package.id),
route("admin.packages.cancel", props.package.id),
{},
{ onSuccess: reload }
);
@@ -132,7 +132,7 @@ async function copyText(text) {
</script>
<template>
<AppLayout :title="`Paket #${package.id}`">
<AdminLayout :title="`Paket #${package.id}`">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
@@ -147,7 +147,7 @@ async function copyText(text) {
</div>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" as-child>
<Link :href="route('packages.index')">
<Link :href="route('admin.packages.index')">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
</Link>
@@ -281,7 +281,7 @@ async function copyText(text) {
:columns="columns"
:data="items.data"
:meta="items"
route-name="packages.show"
route-name="admin.packages.show"
:route-params="{ id: package.id }"
>
<template #cell-target="{ row }">
@@ -333,5 +333,5 @@ async function copyText(text) {
<div v-if="refreshing" class="mt-2 text-xs text-muted-foreground">
Osveževanje ...
</div>
</AppLayout>
</AdminLayout>
</template>
+11 -23
View File
@@ -2,13 +2,7 @@
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { useForm, Link } from "@inertiajs/vue3";
import { KeyRoundIcon, ArrowLeftIcon, SaveIcon } from "lucide-vue-next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
@@ -42,16 +36,12 @@ function submit() {
<CardHeader>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3">
<div
class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"
>
<div class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary">
<KeyRoundIcon class="h-5 w-5" />
</div>
<div>
<CardTitle>Uredi dovoljenje</CardTitle>
<CardDescription
>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription
>
<CardDescription>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription>
</div>
</div>
<Button variant="ghost" size="sm" as-child>
@@ -63,6 +53,7 @@ function submit() {
</div>
</CardHeader>
<CardContent>
<form @submit.prevent="submit" class="space-y-6">
<div class="grid sm:grid-cols-2 gap-6">
<div class="space-y-2">
@@ -95,19 +86,16 @@ function submit() {
class="flex items-center gap-2 text-sm cursor-pointer"
>
<Checkbox
:default-value="form.roles.includes(r.id)"
@update:model-value="
(checked) => {
if (checked) form.roles.push(r.id);
else form.roles = form.roles.filter((id) => id !== r.id);
}
"
:value="r.id"
:checked="form.roles.includes(r.id)"
@update:checked="(checked) => {
if (checked) form.roles.push(r.id)
else form.roles = form.roles.filter(id => id !== r.id)
}"
/>
<span
><span class="font-medium">{{ r.name }}</span>
<span class="text-xs text-muted-foreground"
>({{ r.slug }})</span
></span
<span class="text-xs text-muted-foreground">({{ r.slug }})</span></span
>
</label>
</div>
+1 -32
View File
@@ -2,7 +2,7 @@
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { useForm, Link, router } from "@inertiajs/vue3";
import { ref, computed } from "vue";
import { SearchIcon, SaveIcon, UserPlusIcon, Link2Icon } from "lucide-vue-next";
import { SearchIcon, SaveIcon, UserPlusIcon } from "lucide-vue-next";
import {
Card,
CardContent,
@@ -48,13 +48,6 @@ const forms = Object.fromEntries(
])
);
const settingsForms = Object.fromEntries(
props.users.map((u) => [
u.id,
useForm({ login_redirect: u.login_redirect ?? "" }),
])
);
function toggle(userId, roleId) {
const form = forms[userId];
const exists = form.roles.includes(roleId);
@@ -147,12 +140,6 @@ function toggleUserActive(userId) {
}
);
}
function submitSettings(userId) {
settingsForms[userId].patch(route("admin.users.settings", { user: userId }), {
preserveScroll: true,
});
}
</script>
<template>
@@ -267,24 +254,6 @@ function submitSettings(userId) {
<div class="text-xs text-muted-foreground font-mono">
{{ user.email }}
</div>
<div class="flex items-center gap-1 mt-1.5">
<Link2Icon class="h-3 w-3 text-muted-foreground shrink-0" />
<Input
v-model="settingsForms[user.id].login_redirect"
type="text"
placeholder="/dashboard"
class="h-6 text-xs px-1.5 w-36"
/>
<Button
@click="submitSettings(user.id)"
:disabled="settingsForms[user.id].processing"
size="sm"
variant="ghost"
class="h-6 px-2 text-xs"
>
<SaveIcon class="h-3 w-3" />
</Button>
</div>
</div>
</div>
</TableCell>
-288
View File
@@ -1,288 +0,0 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { computed, ref } from "vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import InputLabel from "@/Components/InputLabel.vue";
import Pagination from "@/Components/Pagination.vue";
import {
PhoneCallIcon,
CheckIcon,
Filter,
ExternalLinkIcon,
MoreHorizontalIcon,
} from "lucide-vue-next";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({
callLaters: Object,
filters: Object,
});
const search = ref(props.filters?.search || "");
const dateFrom = ref(props.filters?.date_from || "");
const dateTo = ref(props.filters?.date_to || "");
const filterPopoverOpen = ref(false);
const appliedFilterCount = computed(() => {
let count = 0;
if (search.value?.trim()) count += 1;
if (dateFrom.value) count += 1;
if (dateTo.value) count += 1;
return count;
});
function applyFilters() {
filterPopoverOpen.value = false;
const params = {};
if (search.value?.trim()) {
params.search = search.value.trim();
}
if (dateFrom.value) {
params.date_from = dateFrom.value;
}
if (dateTo.value) {
params.date_to = dateTo.value;
}
router.get(route("callLaters.index"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}
function clearFilters() {
search.value = "";
dateFrom.value = "";
dateTo.value = "";
applyFilters();
}
function markDone(item) {
router.patch(
route("callLaters.complete", item.id),
{},
{
preserveScroll: true,
}
);
}
function openAndComplete(item) {
router.patch(
route("callLaters.complete", item.id),
{},
{
preserveScroll: false,
onSuccess: () => {
if (item.client_case?.uuid) {
router.visit(route("clientCase.show", { client_case: item.client_case.uuid }));
}
},
}
);
}
function isOverdue(item) {
if (!item.call_back_at) return false;
// Strip Z so the value is parsed as local time (datetimes are stored as local time with a wrong Z suffix)
const localDateStr = item.call_back_at.replace("Z", "").replace("T", " ");
return new Date(localDateStr) < new Date();
}
const columns = [
{ key: "person", label: "Stranka / Primer", sortable: false },
{ key: "contract", label: "Pogodba", sortable: false },
{ key: "call_back_at", label: "Datum klica", sortable: false },
{ key: "user", label: "Agent", sortable: false },
{ key: "note", label: "Opomba", sortable: false },
{ key: "actions", label: "", sortable: false, class: "w-12" },
];
</script>
<template>
<AppLayout title="Pokliči kasneje">
<template #header></template>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<PhoneCallIcon :size="18" />
<CardTitle class="uppercase">Pokliči kasneje</CardTitle>
</div>
</template>
<DataTable
:columns="columns"
:data="callLaters.data || []"
:meta="callLaters"
:search="search"
route-name="callLaters.index"
:show-toolbar="true"
:show-pagination="false"
:hoverable="true"
row-key="id"
empty-text="Ni zakazanih klicev."
:row-class="(row) => (isOverdue(row) ? 'bg-red-50 dark:bg-red-950/20' : '')"
>
<template #toolbar-filters>
<AppPopover
v-model:open="filterPopoverOpen"
align="start"
content-class="w-[420px]"
>
<template #trigger>
<Button variant="outline" size="sm" class="gap-2">
<Filter class="h-4 w-4" />
Filtri
<span
v-if="appliedFilterCount > 0"
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
>
{{ appliedFilterCount }}
</span>
</Button>
</template>
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Filtri klicev</h4>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<InputLabel>Iskanje (stranka)</InputLabel>
<Input
v-model="search"
type="text"
placeholder="Ime stranke..."
@keydown.enter="applyFilters"
/>
</div>
<div class="space-y-1.5">
<InputLabel>Datum od</InputLabel>
<Input v-model="dateFrom" type="date" />
</div>
<div class="space-y-1.5">
<InputLabel>Datum do</InputLabel>
<Input v-model="dateTo" type="date" />
</div>
<div class="flex justify-end gap-2 pt-2 border-t">
<Button
type="button"
variant="outline"
size="sm"
:disabled="appliedFilterCount === 0"
@click="clearFilters"
>
Počisti
</Button>
<Button type="button" size="sm" @click="applyFilters">
Uporabi
</Button>
</div>
</div>
</div>
</AppPopover>
</template>
<template #cell-person="{ row }">
<div>
<Link
v-if="row.client_case"
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
class="font-medium text-indigo-600 hover:underline"
>
{{ row.client_case.person?.full_name || "-" }}
</Link>
<span v-else class="text-muted-foreground">-</span>
</div>
</template>
<template #cell-contract="{ row }">
<span v-if="row.contract">{{ row.contract.reference }}</span>
<span v-else class="text-muted-foreground">-</span>
</template>
<template #cell-call_back_at="{ row }">
<span
:class="[
'font-medium',
isOverdue(row) ? 'text-red-600 dark:text-red-400' : '',
]"
>
{{ fmtDateTime(row.call_back_at) }}
</span>
<span v-if="isOverdue(row)" class="ml-2 text-xs text-red-500 font-semibold">
Zamuda
</span>
</template>
<template #cell-user="{ row }">
<span v-if="row.user">{{ row.user.name }}</span>
<span v-else class="text-muted-foreground">-</span>
</template>
<template #cell-note="{ row }">
<span class="line-clamp-2 text-sm text-muted-foreground">
{{ row.activity?.note || "-" }}
</span>
</template>
<template #cell-actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="icon" variant="ghost" class="h-8 w-8">
<MoreHorizontalIcon class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="markDone(row)">
<CheckIcon class="mr-2 h-4 w-4" />
Opravljeno
</DropdownMenuItem>
<DropdownMenuItem
v-if="row.client_case?.uuid"
@click="openAndComplete(row)"
>
<ExternalLinkIcon class="mr-2 h-4 w-4" />
Odpri in opravi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTable>
<div class="border-t border-gray-200 p-4">
<Pagination
:links="callLaters.links"
:from="callLaters.from"
:to="callLaters.to"
:total="callLaters.total"
:per-page="callLaters.per_page || 50"
:last-page="callLaters.last_page"
:current-page="callLaters.current_page"
/>
</div>
</AppCard>
</div>
</div>
</AppLayout>
</template>

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