Compare commits

..

No commits in common. "master" and "Development" have entirely different histories.

91 changed files with 1969 additions and 5058 deletions

View File

@ -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.');
}
}

View File

@ -12,6 +12,7 @@
use App\Models\SmsTemplate;
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;
@ -29,7 +30,7 @@ public function index(Request $request): Response
->latest('id')
->paginate($perPage);
return Inertia::render('Packages/Index', [
return Inertia::render('Admin/Packages/Index', [
'packages' => $packages,
]);
}
@ -69,7 +70,7 @@ public function create(Request $request): Response
})
->values();
return Inertia::render('Packages/Create', [
return Inertia::render('Admin/Packages/Create', [
'profiles' => $profiles,
'senders' => $senders,
'templates' => $templates,
@ -212,7 +213,7 @@ public function show(Package $package, SmsService $sms): Response
}
}
return Inertia::render('Packages/Show', [
return Inertia::render('Admin/Packages/Show', [
'package' => $package,
'items' => $items,
'preview' => $preview,

View File

@ -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');
}
}

View File

@ -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.');
}
}

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',
@ -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'],
@ -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,15 +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(),
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
]);
}
@ -1135,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',
]);
@ -1153,7 +1119,6 @@ public function archiveBatch(Request $request)
// Skip if contract is already archived (active = 0)
if (!$contract->active) {
$skippedCount++;
continue;
}
@ -1243,7 +1208,7 @@ 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,
@ -1380,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) {
@ -1426,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,

View File

@ -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(),

View File

@ -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.');
}
}

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;
@ -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

View File

@ -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(),

View File

@ -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.');
}
}

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)
@ -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');
}
@ -140,14 +142,8 @@ public function createEmail(Person $person, Request $request)
'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'],
@ -168,16 +164,10 @@ public function updateEmail(Person $person, int $email_id, Request $request)
'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');
@ -214,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)
@ -246,6 +238,7 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete();
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
}

View File

@ -10,14 +10,19 @@
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 = [
$query = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->with([
'contract' => function ($q) {
$q->with([
'type:id,name',
@ -28,22 +33,19 @@ public function index(Request $request): \Inertia\Response
'clientCase.client.person:id,full_name',
]);
},
];
$baseQuery = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->with($eagerLoad);
])
->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' => [

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.';
}
}

View File

@ -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();

View File

@ -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'],
];
}
}

View File

@ -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'],
];
}
}

View File

@ -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'],
];
}
}

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);
}
}

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([

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);

View File

@ -18,7 +18,6 @@ class Activity extends Model
protected $fillable = [
'due_date',
'call_back_at',
'amount',
'note',
'action_id',
@ -28,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',
@ -154,9 +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);
}
}

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);
}
}

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',
];
}

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);
}
}

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',
];
}

View File

@ -31,7 +31,6 @@ class User extends Authenticatable
'email',
'password',
'active',
'login_redirect',
];
/**

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);
//
}
/**

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))

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,8 +40,9 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Co
$query->forSegment($segmentId);
}
$perPage = max(1, min(100, $perPage));
return $query->get();
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
}
/**

View File

@ -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'],
],
];
}
}

View File

@ -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],

View File

@ -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,
]);
}
}

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");

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",

1773
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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');
});
}
};

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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');
});
}
};

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) {

180
package-lock.json generated
View File

@ -46,7 +46,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@inertiajs/vue3": "^3.0",
"@inertiajs/vue3": "2.0",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18",
@ -952,35 +952,26 @@
}
},
"node_modules/@inertiajs/core": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.0.3.tgz",
"integrity": "sha512-/4sW/cfNpvujjVOZlB5UNypLGNySs7X7V8IMLNSK8+3j1KsUYGS5wpLd9EqAu8wy8RiW7PPra2rPwB6Lx/ACow==",
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.0.17.tgz",
"integrity": "sha512-tvYoqiouQSJrP7i7zVq61yyuEjlL96UU4nkkOWtOajXZlubGN4XrgRpnygpDk1KBO8V2yBab3oUZm+aZImwTHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"es-toolkit": "^1.33.0",
"laravel-precognition": "^2.0.0"
},
"peerDependencies": {
"axios": "^1.13.2"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
"axios": "^1.8.2",
"es-toolkit": "^1.34.1",
"qs": "^6.9.0"
}
},
"node_modules/@inertiajs/vue3": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-3.0.3.tgz",
"integrity": "sha512-bhJN+GS66g1tYH1p6flKkG1N8oaT5J7ZLqBkavN9mHC6bVfoQCUG6sCuA07WTDfo9tDaxU89wsSSAf4mhn3SuA==",
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.0.17.tgz",
"integrity": "sha512-Al0IMHQSj5aTQBLUAkljFEMCw4YRwSiOSKzN8LAbvJpKwvJFgc/wSj3wVVpr/AO9y9mz1w2mtvjnDoOzsntPLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inertiajs/core": "3.0.3",
"es-toolkit": "^1.33.0",
"laravel-precognition": "^2.0.0"
"@inertiajs/core": "2.0.17",
"es-toolkit": "^1.33.0"
},
"peerDependencies": {
"vue": "^3.0.0"
@ -3813,9 +3804,9 @@
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
"dev": true,
"license": "MIT",
"workspaces": [
@ -4381,24 +4372,6 @@
"node": ">=0.10.0"
}
},
"node_modules/laravel-precognition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz",
"integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-toolkit": "^1.32.0"
},
"peerDependencies": {
"axios": "^1.4.0"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/laravel-vite-plugin": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
@ -4902,6 +4875,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
@ -5112,6 +5098,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
@ -5359,6 +5361,82 @@
"node": ">= 0.4"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/skema": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz",
@ -5951,6 +6029,24 @@
"which": "bin/which"
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@ -7,7 +7,7 @@
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"devDependencies": {
"@inertiajs/vue3": "^3.0",
"@inertiajs/vue3": "2.0",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18",

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>

View File

@ -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>

View File

@ -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"

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>

View File

@ -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"
class="max-w-full max-h-full mx-auto object-contain"
/>
<!-- Zoom level badge -->
<div
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
>
{{ Math.round(imageScale * 100) }}%
</div>
<!-- Reset button -->
<Button
v-if="imageScale > fitScale + 0.01"
size="icon-sm"
variant="secondary"
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
title="Ponastavi pogled"
@click.stop="resetImageView"
>
<RotateCcwIcon class="h-3 w-3" />
</Button>
<!-- Hint -->
<div
v-if="imageScale <= fitScale + 0.01"
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
>
Kolesce za povečavo / pomanjšavo · Povleči za premik
</div>
</div>
</template>
<!-- Text/CSV/XML Viewer -->

View File

@ -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,22 +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(),
decision_ids: z.array(z.string()).optional().default([]),
})
);
@ -54,13 +43,9 @@ const form = useForm({
value: "",
label: "",
receive_auto_mails: false,
decision_ids: [],
},
});
// Whether to limit sending to specific decisions (UI-only toggle)
const limitToDecisions = ref(false);
const processing = ref(false);
const close = () => {
@ -72,44 +57,22 @@ const close = () => {
};
const resetForm = () => {
limitToDecisions.value = false;
form.resetForm({
values: {
value: "",
label: "",
receive_auto_mails: 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: () => {
@ -135,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: () => {
@ -176,13 +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,
decision_ids: existingDecisionIds,
});
} else {
resetForm();
@ -271,36 +228,6 @@ const onConfirm = () => {
</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>

View File

@ -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,8 +452,7 @@ const open = computed({
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[65vh] pr-1">
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
<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>
@ -584,8 +582,8 @@ const open = computed({
</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
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
@ -606,7 +604,6 @@ const open = computed({
</FormItem>
</FormField>
</form>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="closeSmsDialog" :disabled="processing">

View File

@ -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

View File

@ -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>

View File

@ -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,
};
}

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"],
},
],
},
]);

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: "SMS paketi",
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>

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"

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,
},
],
},
];

View File

@ -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";
@ -112,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"));
},
});
}
@ -202,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" },
});
@ -268,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);
}
@ -312,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];
@ -337,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
@ -520,7 +520,7 @@ const numbersCount = computed(() => {
</div>
<div class="flex justify-end gap-2">
<Button
@click="router.visit(route('packages.index'))"
@click="router.visit(route('admin.packages.index'))"
variant="outline"
>
Prekliči
@ -703,7 +703,7 @@ const numbersCount = computed(() => {
Izbrano: {{ selectedContractIds.size }}
</Badge>
<Button
@click="router.visit(route('packages.index'))"
@click="router.visit(route('admin.packages.index'))"
variant="outline"
>
Prekliči
@ -806,5 +806,5 @@ const numbersCount = computed(() => {
</Card>
</TabsContent>
</Tabs>
</AppLayout>
</AdminLayout>
</template>

View File

@ -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";
@ -48,7 +48,7 @@ function getStatusVariant(status) {
}
function goShow(id) {
router.visit(route("packages.show", id));
router.visit(route("admin.packages.show", id));
}
function openDeleteDialog(pkg) {
@ -60,7 +60,7 @@ function openDeleteDialog(pkg) {
function confirmDelete() {
if (!packageToDelete.value) return;
deletingId.value = packageToDelete.value.id;
router.delete(route("packages.destroy", packageToDelete.value.id), {
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
onSuccess: () => {
router.reload({ only: ["packages"] });
},
@ -74,7 +74,7 @@ function confirmDelete() {
</script>
<template>
<AppLayout title="SMS paketi">
<AdminLayout title="SMS paketi">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
@ -82,7 +82,7 @@ function confirmDelete() {
<PackageIcon class="h-5 w-5 text-muted-foreground" />
<CardTitle>SMS paketi</CardTitle>
</div>
<Link :href="route('packages.create')">
<Link :href="route('admin.packages.create')">
<Button>
<PlusIcon class="h-4 w-4" />
Nov paket
@ -109,7 +109,7 @@ function confirmDelete() {
:columns="columns"
:data="packages.data"
:meta="packages"
route-name="packages.index"
route-name="admin.packages.index"
>
<template #cell-name="{ row }">
<span class="text-sm">{{ row.name ?? "—" }}</span>
@ -172,5 +172,5 @@ function confirmDelete() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AppLayout>
</AdminLayout>
</template>

View File

@ -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>

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>

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>

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>

View File

@ -58,8 +58,6 @@ const form = useInertiaForm({
send_auto_mail: true,
attach_documents: false,
attachment_document_ids: [],
call_back_at_date: null,
call_back_at_time: null,
});
watch(
@ -129,20 +127,6 @@ const store = async () => {
const isMultipleContracts = contractUuids && contractUuids.length > 1;
const buildCallBackAt = (date, time) => {
if (!date) return null;
const t = time || '00:00';
const [h, m] = t.split(':');
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return null;
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const dy = String(d.getDate()).padStart(2, '0');
const hh = String(Number(h || 0)).padStart(2, '0');
const mm = String(Number(m || 0)).padStart(2, '0');
return `${y}-${mo}-${dy} ${hh}:${mm}:00`;
};
form
.transform((data) => ({
...data,
@ -154,16 +138,11 @@ const store = async () => {
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
? data.attachment_document_ids
: [],
call_back_at: hasCallLaterEvent.value
? buildCallBackAt(data.call_back_at_date, data.call_back_at_time)
: null,
call_back_at_date: undefined,
call_back_at_time: undefined,
}))
.post(route("clientCase.activity.store", props.client_case), {
onSuccess: () => {
close();
form.reset("due_date", "amount", "note", "contract_uuids", "call_back_at_date", "call_back_at_time");
form.reset("due_date", "amount", "note", "contract_uuids");
emit("saved");
},
});
@ -177,22 +156,6 @@ const currentDecision = () => {
decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0] || null
);
};
const hasCallLaterEvent = computed(() => {
const d = currentDecision();
if (!d) return false;
return Array.isArray(d.events) && d.events.some((e) => e.key === 'add_call_later');
});
watch(
() => hasCallLaterEvent.value,
(has) => {
if (!has) {
form.call_back_at_date = null;
form.call_back_at_time = null;
}
}
);
const showSendAutoMail = () => {
const d = currentDecision();
return !!(d && d.auto_mail && d.email_template_id);
@ -446,26 +409,6 @@ watch(
/>
</div>
<div v-if="hasCallLaterEvent" class="space-y-2">
<Label>Datum in ura povratnega klica</Label>
<div class="flex gap-2">
<DatePicker
v-model="form.call_back_at_date"
format="dd.MM.yyyy"
:error="form.errors.call_back_at"
class="flex-1"
/>
<input
v-model="form.call_back_at_time"
type="time"
class="flex-1 border rounded-md px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<p v-if="form.errors.call_back_at" class="text-xs text-destructive">
{{ form.errors.call_back_at }}
</p>
</div>
<div class="space-y-2">
<Label for="activityAmount">Znesek</Label>
<CurrencyInput
@ -537,7 +480,7 @@ watch(
/>
<div class="wrap-anywhere">
<p>
<span>{{ doc.name }}.{{ doc.extension }}</span>
{{ doc.original_name || doc.name }}
</p>
<span class="text-xs text-gray-400"
>({{ doc.extension?.toUpperCase() || "" }},

View File

@ -741,16 +741,8 @@ const copyToClipboard = async (text) => {
<span class="text-gray-500">D:</span>
<span class="ml-1">{{ fmtDate(row.due_date) }}</span>
</div>
<div v-if="row.call_back_at" class="leading-tight">
<span class="text-gray-500">K:</span>
<span class="ml-1">{{ fmtDateTime(row.call_back_at) }}</span>
</div>
<div
v-if="
!row.due_date &&
(!row.amount || Number(row.amount) === 0) &&
!row.call_back_at
"
v-if="!row.due_date && (!row.amount || Number(row.amount) === 0)"
class="text-gray-400"
>

View File

@ -15,8 +15,6 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
import PaymentDialog from "./PaymentDialog.vue";
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
import InstallmentDialog from "./InstallmentDialog.vue";
import ViewInstallmentsDialog from "./ViewInstallmentsDialog.vue";
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
@ -33,7 +31,6 @@ import {
faSpinner,
faTags,
faFolderOpen,
faArrowUp,
} from "@fortawesome/free-solid-svg-icons";
import EmptyState from "@/Components/EmptyState.vue";
import { Button } from "@/Components/ui/button";
@ -447,52 +444,6 @@ const closePaymentsDialog = () => {
selectedContract.value = null;
};
// Installments
const showInstallmentDialog = ref(false);
const installmentContract = ref(null);
const installmentForm = useForm({
amount: null,
currency: "EUR",
installment_at: null,
reference: "",
});
const openInstallmentDialog = (c) => {
installmentContract.value = c;
installmentForm.reset();
installmentForm.installment_at = todayStr.value;
showInstallmentDialog.value = true;
};
const closeInstallmentDialog = () => {
showInstallmentDialog.value = false;
installmentContract.value = null;
};
const submitInstallment = () => {
if (!installmentContract.value?.account?.id) return;
const accountId = installmentContract.value.account.id;
installmentForm.post(route("accounts.installments.store", { account: accountId }), {
preserveScroll: true,
onSuccess: () => {
closeInstallmentDialog();
router.reload({ only: ["contracts", "activities"] });
},
});
};
const showInstallmentsDialog = ref(false);
const openInstallmentsDialog = (c) => {
selectedContract.value = c;
showInstallmentsDialog.value = true;
};
const closeInstallmentsDialog = () => {
showInstallmentsDialog.value = false;
selectedContract.value = null;
};
// Meta edit dialog
const showMetaEditDialog = ref(false);
@ -538,7 +489,7 @@ const availableSegmentsCount = computed(() => {
:empty-icon="faFolderOpen"
empty-text="Ni pogodb"
empty-description="Za ta primer še ni ustvarjenih pogodb. Ustvarite novo pogodbo za začetek."
:show-pagination="true"
:show-pagination="false"
:show-toolbar="true"
:hoverable="true"
>
@ -799,6 +750,7 @@ const availableSegmentsCount = computed(() => {
<!-- Add Activity -->
<ActionMenuItem
v-if="row.active"
:icon="faListCheck"
label="Dodaj aktivnost"
@click="onAddActivity(row)"
@ -881,26 +833,6 @@ const availableSegmentsCount = computed(() => {
@click="openPaymentDialog(row)"
/>
<div class="my-1 border-t border-gray-100" />
<!-- Installments -->
<div
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
>
Obroki
</div>
<ActionMenuItem
:icon="faCircleInfo"
label="Pokaži obroke"
@click="openInstallmentsDialog(row)"
/>
<ActionMenuItem
v-if="row.active && row?.account"
:icon="faArrowUp"
label="Dodaj obrok"
@click="openInstallmentDialog(row)"
/>
<!-- Archive -->
<template v-if="edit">
<div class="my-1 border-t border-gray-100" />
@ -1006,20 +938,6 @@ const availableSegmentsCount = computed(() => {
:edit="edit"
/>
<InstallmentDialog
:show="showInstallmentDialog"
:form="installmentForm"
@close="closeInstallmentDialog"
@submit="submitInstallment"
/>
<ViewInstallmentsDialog
:show="showInstallmentsDialog"
:contract="selectedContract"
@close="closeInstallmentsDialog"
:edit="edit"
/>
<ContractMetaEditDialog
:show="showMetaEditDialog"
:client_case="client_case"

View File

@ -1,82 +0,0 @@
<script setup>
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import CurrencyInput from "@/Components/CurrencyInput.vue";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import DatePicker from "@/Components/DatePicker.vue";
const props = defineProps({
show: { type: Boolean, default: false },
form: { type: Object, required: true },
});
const emit = defineEmits(["close", "submit"]);
const onClose = () => emit("close");
const onSubmit = () => emit("submit");
</script>
<template>
<CreateDialog
:show="show"
title="Dodaj obrok"
confirm-text="Shrani"
:processing="form.processing"
@close="onClose"
@confirm="onSubmit"
>
<div class="space-y-4">
<div class="space-y-2">
<Label for="installmentAmount">Znesek</Label>
<CurrencyInput
id="installmentAmount"
v-model="form.amount"
:precision="{ min: 0, max: 2 }"
placeholder="0,00"
class="w-full"
/>
<p v-if="form.errors?.amount" class="text-sm text-red-600">
{{ form.errors.amount }}
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="installmentCurrency">Valuta</Label>
<Input
id="installmentCurrency"
type="text"
v-model="form.currency"
maxlength="3"
placeholder="EUR"
/>
<p v-if="form.errors?.currency" class="text-sm text-red-600">
{{ form.errors.currency }}
</p>
</div>
<div class="space-y-2">
<Label for="installmentDate">Datum</Label>
<DatePicker
id="installmentDate"
v-model="form.installment_at"
format="dd.MM.yyyy"
:error="form.errors?.installment_at"
/>
</div>
</div>
<div class="space-y-2">
<Label for="installmentReference">Sklic</Label>
<Input
id="installmentReference"
type="text"
v-model="form.reference"
placeholder="Sklic"
/>
<p v-if="form.errors?.reference" class="text-sm text-red-600">
{{ form.errors.reference }}
</p>
</div>
</div>
</CreateDialog>
</template>

View File

@ -1,160 +0,0 @@
<script setup>
import DialogModal from "@/Components/DialogModal.vue";
import { ref, watch, computed } from "vue";
import { router } from "@inertiajs/vue3";
import axios from "axios";
const props = defineProps({
show: { type: Boolean, default: false },
contract: { type: Object, default: null },
edit: { type: Boolean, default: true },
});
const emit = defineEmits(["close"]);
const installments = ref([]);
const loading = ref(false);
const contractRef = computed(() => props.contract?.reference || "—");
const accountId = computed(() => props.contract?.account?.id || null);
function formatDate(d) {
if (!d) return "-";
const dt = new Date(d);
return isNaN(dt.getTime()) ? "-" : dt.toLocaleDateString("de");
}
async function loadInstallments() {
if (!accountId.value) {
installments.value = [];
return;
}
loading.value = true;
try {
const { data } = await axios.get(
route("accounts.installments.list", { account: accountId.value })
);
installments.value = data.installments || [];
} finally {
loading.value = false;
}
}
function close() {
emit("close");
installments.value = [];
}
function deleteInstallment(installmentId) {
if (!accountId.value) return;
router.delete(
route("accounts.installments.destroy", {
account: accountId.value,
installment: installmentId,
}),
{
preserveScroll: true,
preserveState: true,
only: ["contracts", "activities"],
onSuccess: async () => {
await loadInstallments();
},
onError: async () => {
await loadInstallments();
},
}
);
}
watch(
() => props.show,
async (visible) => {
if (visible) {
await loadInstallments();
}
}
);
watch(
() => props.contract?.account?.id,
async () => {
if (props.show) {
await loadInstallments();
}
}
);
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
Obroki za pogodbo <span class="text-gray-600">{{ contractRef }}</span>
</template>
<template #content>
<div>
<div v-if="loading" class="text-sm text-gray-500">Nalaganje</div>
<template v-else>
<div v-if="installments.length === 0" class="text-sm text-gray-500">Ni obrokov.</div>
<div v-else class="divide-y divide-gray-100 border rounded">
<div
v-for="i in installments"
:key="i.id"
class="px-3 py-2 flex items-center justify-between"
>
<div>
<div class="text-sm text-gray-800">
{{
Intl.NumberFormat("de-DE", {
style: "currency",
currency: i.currency || "EUR",
}).format(i.amount ?? 0)
}}
</div>
<div class="text-xs text-gray-500">
<span>{{ formatDate(i.installment_at) }}</span>
<span v-if="i.reference" class="ml-2">Sklic: {{ i.reference }}</span>
<span v-if="i.balance_before !== undefined" class="ml-2">
Stanje pred:
{{
Intl.NumberFormat("de-DE", {
style: "currency",
currency: i.currency || "EUR",
}).format(i.balance_before ?? 0)
}}
</span>
</div>
</div>
<div class="flex items-center gap-2" v-if="edit">
<button
type="button"
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
@click="deleteInstallment(i.id)"
title="Izbriši obrok"
>
<span class="text-sm">Briši</span>
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<button
type="button"
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
@click="loadInstallments"
>
Osveži
</button>
<button
type="button"
class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
@click="close"
>
Zapri
</button>
</div>
</template>
</DialogModal>
</template>

View File

@ -31,7 +31,7 @@ import {
const props = defineProps({
client: Object,
client_case: Object,
contracts: { type: Array, default: () => [] }, // Resource Collection with data/links/meta
contracts: Object, // Resource Collection with data/links/meta
activities: Object, // Resource Collection with data/links/meta
contract_types: Array,
account_types: { type: Array, default: () => [] },
@ -46,7 +46,7 @@ const props = defineProps({
// Extract contracts array from Resource Collection
const contractsArray = computed(() => {
return props.contracts || [];
return props.contracts?.data || [];
});
// Contracts are always paginated now (Resource Collection)
@ -356,6 +356,19 @@ const submitAttachSegment = () => {
@create="openDrawerCreateContract"
@attach-segment="openAttachSegment"
/>
<div v-if="contractsPaginated" class="border-t border-gray-200 p-4">
<Pagination
:links="contracts.links"
:from="contracts.from"
:to="contracts.to"
:total="contracts.total"
:per-page="contracts.per_page || 50"
:last-page="contracts.last_page"
:current-page="contracts.current_page"
per-page-param="contracts_per_page"
page-param="contracts_page"
/>
</div>
</div>
</Card>
</div>

View File

@ -32,7 +32,7 @@ const chartData = computed(() => {
}
return props.trends.labels.map((label, i) => ({
date: new Date(label + "T00:00:00"),
date: new Date(label),
dateLabel: label,
completed: props.trends.field_jobs_completed[i] || 0,
assigned: props.trends.field_jobs[i] || 0,
@ -140,7 +140,7 @@ const crosshairLabelFormatter = (value) => {
type="x"
:tick-line="false"
:grid-line="false"
:tick-values="chartData.map((d) => d.date)"
:num-ticks="7"
:tick-format="
(d) => {
const date = new Date(d);

View File

@ -73,8 +73,13 @@ function safeCaseHref(uuid, segment = null) {
v-if="fieldJobsAssignedToday && fieldJobsAssignedToday.length > 0"
>
<div class="flex flex-col gap-1 px-1">
<template v-for="f in fieldJobsAssignedToday" :key="f.id">
<Item v-if="f.contract" variant="outline" size="sm" as-child>
<Item
v-for="f in fieldJobsAssignedToday"
:key="f.id"
variant="outline"
size="sm"
as-child
>
<a :href="safeCaseHref(f.contract.client_case_uuid, f.contract.segment_id)">
<ItemMedia>
<span class="w-2 h-2 mt-2 rounded-full bg-primary" />
@ -93,7 +98,6 @@ function safeCaseHref(uuid, segment = null) {
</ItemActions>
</a>
</Item>
</template>
</div>
</ScrollArea>
<div

View File

@ -4,7 +4,7 @@ import { Card, CardContent } from "@/Components/ui/card";
const props = defineProps({
label: String,
value: [String, Number],
icon: [Object, Function],
icon: Object,
iconBg: {
type: String,
default: "bg-primary/10",

View File

@ -263,14 +263,10 @@ function formatDate(value) {
if (isNaN(d)) {
return value;
}
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Ljubljana",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).formatToParts(d);
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
return `${map.day}.${map.month}.${map.year}`;
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
return `${dd}.${mm}.${yyyy}`;
}
function formatCurrencyEUR(value) {

View File

@ -148,7 +148,7 @@ function formatDateTimeNoSeconds(value) {
last_page: imports?.meta?.last_page,
from: imports?.meta?.from,
to: imports?.meta?.to,
links: imports?.meta?.links,
links: imports?.links,
}"
route-name="imports.index"
:only-props="['imports']"

View File

@ -163,7 +163,9 @@ const props = defineProps({
<template>
<AppLayout title="Uvozne predloge">
<template #header> </template>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uvozne predloge</h2>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">

View File

@ -17,12 +17,6 @@ import {
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { Separator } from "@/Components/ui/separator";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/Components/ui/accordion";
import {
Dialog,
DialogContent,
@ -56,6 +50,9 @@ import {
Download,
Eye,
Building2,
Phone,
Mail,
MapPin,
Activity,
} from "lucide-vue-next";
@ -281,11 +278,16 @@ const clientSummary = computed(() => {
<template #header>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<Button variant="outline" size="sm" @click="router.visit(route('phone.index'))">
<ArrowLeft />
<Button
variant="ghost"
size="sm"
@click="router.visit(route('phone.index'))"
class="shrink-0"
>
<ArrowLeft class="w-4 h-4 mr-1" />
Nazaj
</Button>
<h2 class="font-semibold text-gray-800 dark:text-gray-100 truncate">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-100 truncate">
{{ client_case?.person?.full_name }}
</h2>
</div>
@ -295,7 +297,7 @@ const clientSummary = computed(() => {
variant="secondary"
class="bg-emerald-100 text-emerald-700 hover:bg-emerald-100"
>
<CheckCircle2 class="w-4 h-4" />
<CheckCircle2 class="w-3 h-3 mr-1" />
Zaključeno danes
</Badge>
<Button
@ -303,25 +305,25 @@ const clientSummary = computed(() => {
@click="confirmComplete = true"
class="bg-green-600 hover:bg-green-700"
>
<CheckCircle2 class="w-4 h-4" />
<CheckCircle2 class="w-4 h-4 mr-2" />
Zaključi
</Button>
</div>
</div>
</template>
<div class="py-4 sm:py-2">
<div class="py-4 sm:py-6">
<div class="mx-auto max-w-5xl px-2 sm:px-4 space-y-4">
<!-- Client details (account holder) -->
<Card class="p-0 py-3 gap-3">
<CardHeader class="px-3 py-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Building2 class="w-5 h-5 text-gray-500" />
<span class="truncate">{{ clientSummary.name }}</span>
<Badge variant="secondary">Naročnik</Badge>
</CardTitle>
</CardHeader>
<CardContent class="px-3">
<CardContent>
<Separator class="mb-4" />
<PersonDetailPhone
:types="types"
@ -332,8 +334,8 @@ const clientSummary = computed(() => {
</Card>
<!-- Person (case person) -->
<Card class="p-0 py-3 gap-3">
<CardHeader class="px-3 py-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<User class="w-5 h-5 text-gray-500" />
<span class="truncate">{{ client_case.person.full_name }}</span>
@ -345,15 +347,8 @@ const clientSummary = computed(() => {
>
{{ client_case.person.description }}
</CardDescription>
<p
v-if="client_case?.person?.employer"
class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-1"
>
<Building2 class="w-3.5 h-3.5 shrink-0" />
{{ client_case.person.employer }}
</p>
</CardHeader>
<CardContent class="px-3">
<CardContent>
<Separator class="mb-4" />
<PersonDetailPhone
:types="types"
@ -364,185 +359,75 @@ const clientSummary = computed(() => {
</Card>
<!-- Contracts assigned to me -->
<Card class="p-0 py-3 gap-1">
<CardHeader class="px-3 py-2 pb-0">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<FileText class="w-5 h-5" />
Pogodbe
</CardTitle>
</CardHeader>
<CardContent class="p-2 space-y-1">
<CardContent class="space-y-3">
<Card
v-for="c in contracts"
:key="c.uuid || c.id"
class="overflow-hidden p-0 gap-2"
class="border-l-4 border-l-indigo-500"
>
<!-- Contract header: reference + type badge -->
<CardHeader class="p-3 pb-2 gap-0">
<div class="flex items-center flex-wrap">
<CardTitle class="text-base font-semibold">
{{ c.reference || "Šifra pogodbe ni določena" }}
<CardHeader class="pb-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<CardTitle class="text-sm">
{{ c.reference || c.uuid }}
</CardTitle>
<Badge v-if="c.type?.name" variant="secondary" class="text-[11px]">
{{ c.type.name }}
</Badge>
</div>
</CardHeader>
<!-- Balance row -->
<div
v-if="c.account"
class="mx-3 rounded-xl bg-red-50 dark:bg-red-950/20 border border-red-100 dark:border-red-900 px-2 py-2 flex items-center justify-between"
>
<div class="flex items-center gap-2 text-red-500">
<Euro class="w-4 h-4 shrink-0" />
<span class="text-xs font-medium uppercase tracking-wide text-red-400"
>Odprto</span
>
</div>
<div v-if="c.account" class="mt-3 flex items-center gap-2">
<Euro class="w-4 h-4 text-gray-400" />
<div class="flex items-baseline gap-2">
<span class="text-xs text-gray-500 uppercase">Odprto</span>
<span
class="text-2xl font-bold text-red-600 dark:text-red-400 tabular-nums"
class="text-lg font-semibold text-gray-900 dark:text-gray-100"
>
{{ formatAmount(c.account.balance_amount) }}
</span>
</div>
<!-- Collapsibles: description, meta, last object -->
<CardContent
v-if="
c.description ||
c.latest_object ||
(c.meta && Object.keys(c.meta).length)
"
class="pt-0 px-0 space-y-0"
>
<!-- Description + Meta + Latest Object Accordion -->
<template
v-if="
c.description ||
(c.meta && Object.keys(c.meta).length) ||
c.latest_object
"
>
<Separator />
<Accordion type="multiple" class="w-full">
<AccordionItem
v-if="c.description"
value="description"
class="border-b-0"
>
<AccordionTrigger
class="px-3 py-2 text-xs font-medium uppercase tracking-wide hover:no-underline"
>
Opis
</AccordionTrigger>
<AccordionContent class="px-3 pb-3">
<p
class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line rounded-lg bg-gray-50 dark:bg-gray-800/50 px-3 py-2.5"
>
{{ c.description }}
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem
v-if="c.meta && Object.keys(c.meta).length"
value="meta"
class="border-b-0"
:class="c.description ? 'border-t' : ''"
>
<AccordionTrigger
class="px-3 py-2 text-xs font-medium uppercase tracking-wide hover:no-underline"
>
<div>
<span class="mr-1">Dodatni podatki</span>
<Badge
class="bg-blue-500 text-white dark:bg-blue-600 h-5 min-w-5 rounded-full px-2 font-mono tabular-nums"
>{{ Object.keys(c.meta).length }}</Badge
>
</div>
</AccordionTrigger>
<AccordionContent class="pb-2">
<div
class="divide-y divide-gray-100 dark:divide-gray-700 rounded-lg border border-gray-100 dark:border-gray-700 overflow-hidden"
>
<div
v-for="(val, key) in c.meta"
:key="key"
class="flex items-center justify-between gap-3 px-3 py-2 bg-white dark:bg-gray-900 even:bg-gray-50/60 dark:even:bg-gray-800/40"
>
</div>
<div class="flex flex-col gap-2 shrink-0">
<Button size="sm" @click="openDrawerAddActivity(c)">
<Plus class="w-4 h-4 mr-1" />
Aktivnost
</Button>
<Button size="sm" variant="secondary" @click="openDocDialog(c)">
<Upload class="w-4 h-4 mr-1" />
Dokument
</Button>
</div>
</div>
</CardHeader>
<CardContent v-if="c.last_object" class="pt-0">
<Separator class="mb-3" />
<div class="space-y-1">
<p class="text-xs text-gray-500 uppercase">Zadnji predmet</p>
<div class="text-sm font-medium text-gray-800 dark:text-gray-200">
{{ c.last_object.name || c.last_object.reference }}
<span
class="text-xs text-gray-500 dark:text-gray-400 shrink-0"
>{{ val?.title || key }}</span
v-if="c.last_object.type"
class="ml-2 text-xs font-normal text-gray-500"
>
<span
class="text-xs font-semibold text-gray-800 dark:text-gray-200 text-right"
>
<template v-if="val?.type === 'date'">{{
formatDateShort(val.value) || val.value || "—"
}}</template>
<template v-else-if="val?.type === 'number'">{{
val.value ?? "—"
}}</template>
<template v-else>{{ val?.value ?? val ?? "" }}</template>
({{ c.last_object.type }})
</span>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem
v-if="c.latest_object"
value="latest_object"
class="border-b-0"
:class="
c.description || (c.meta && Object.keys(c.meta).length)
? 'border-t'
: ''
"
>
<AccordionTrigger
class="px-3 py-2 text-xs font-medium uppercase tracking-wide hover:no-underline"
>
Zadnji predmet
</AccordionTrigger>
<AccordionContent class="px-3 pb-3">
<div
class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line rounded-lg bg-gray-50 dark:bg-gray-800/50 px-3 py-2.5"
v-if="c.last_object.description"
class="text-sm text-gray-600 dark:text-gray-400"
>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">
{{ c.latest_object.name || c.latest_object.reference }}
<span
v-if="c.latest_object.type"
class="ml-1.5 text-xs font-normal text-gray-400"
>({{ c.latest_object.type }})</span
>
</p>
<p
v-if="c.latest_object.description"
class="text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{{ c.latest_object.description }}
</p>
{{ c.last_object.description }}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</template>
</CardContent>
<!-- Action buttons: full-width row at bottom -->
<div class="grid grid-cols-2 gap-0 border-t mt-0">
<button
class="flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary hover:bg-primary/5 active:bg-primary/10 transition-colors border-r"
@click="openDrawerAddActivity(c)"
>
<Plus class="w-4 h-4" />
Aktivnost
</button>
<button
class="flex items-center justify-center gap-2 py-3 text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 active:bg-gray-100 transition-colors"
@click="openDocDialog(c)"
>
<Upload class="w-4 h-4" />
Dokument
</button>
</div>
</Card>
<p
v-if="!contracts?.length"
@ -554,27 +439,27 @@ const clientSummary = computed(() => {
</Card>
<!-- Activities -->
<Card class="p-0 py-2 gap-2">
<CardHeader class="px-3 py-2">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="flex items-center gap-2">
<Activity class="w-5 h-5" />
Aktivnosti
</CardTitle>
<Button size="sm" @click="openDrawerAddActivity()">
<Plus class="w-4 h-4" />
<Plus class="w-4 h-4 mr-1" />
Nova
</Button>
</div>
</CardHeader>
<CardContent class="space-y-1 px-2">
<CardContent class="space-y-3">
<Card
v-for="a in activities"
:key="a.id"
class="bg-gray-50/70 dark:bg-gray-800/50 p-0 py-2 gap-2"
class="bg-gray-50/70 dark:bg-gray-800/50"
>
<CardHeader class="px-3 py-2">
<div class="flex items-start justify-between">
<CardHeader>
<div class="flex items-start justify-between gap-3">
<CardTitle class="text-sm font-medium truncate">
{{ activityActionLine(a) || "Aktivnost" }}
</CardTitle>
@ -595,7 +480,7 @@ const clientSummary = computed(() => {
</div>
</div>
</CardHeader>
<CardContent class="p-2 pt-0 space-y-2">
<CardContent class="pt-0 space-y-2">
<div class="flex flex-wrap gap-1.5">
<Badge v-if="a.contract" variant="secondary" class="text-[10px]">
<FileText class="w-3 h-3 mr-1" />
@ -621,10 +506,7 @@ const clientSummary = computed(() => {
{{ a.status }}
</Badge>
</div>
<p
v-if="a.note"
class="text-sm text-gray-900 dark:text-gray-300 whitespace-pre-line rounded-lg bg-secondary dark:bg-gray-800/50 p-2"
>
<p v-if="a.note" class="text-sm text-gray-700 dark:text-gray-300">
{{ a.note }}
</p>
</CardContent>
@ -639,8 +521,8 @@ const clientSummary = computed(() => {
</Card>
<!-- Documents (case + assigned contracts) -->
<Card class="p-0 py-2 gap-2">
<CardHeader class="px-3 py-2">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="flex items-center gap-2">
<FileText class="w-5 h-5" />
@ -664,7 +546,7 @@ const clientSummary = computed(() => {
{{ d.name || d.original_name }}
</div>
<div
class="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-2 flex-wrap"
class="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-2"
>
<Badge
v-if="d.contract_reference"
@ -674,11 +556,6 @@ const clientSummary = computed(() => {
Pogodba: {{ d.contract_reference }}
</Badge>
<Badge v-else variant="outline" class="text-[10px]"> Primer </Badge>
<span
v-if="d.mime_type"
class="text-[10px] text-gray-400 font-mono"
>{{ d.mime_type }}</span
>
<span v-if="d.created_at" class="flex items-center gap-1">
<Calendar class="w-3 h-3" />
{{ new Date(d.created_at).toLocaleDateString("sl-SI") }}

View File

@ -2,6 +2,13 @@
import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue";
import { Badge } from "@/Components/ui/badge";
import { Button } from "@/Components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Input } from "@/Components/ui/input";
import {
Select,
@ -10,121 +17,91 @@ import {
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Separator } from "@/Components/ui/separator";
import { Skeleton } from "@/Components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import { InfiniteScroll, router } from "@inertiajs/vue3";
import {
computed,
defineComponent,
h,
onMounted,
onUnmounted,
ref,
watch,
} from "vue";
import { router } from "@inertiajs/vue3";
import { computed, ref, watch } from "vue";
import { useDebounceFn } from "@vueuse/core";
import {
CalendarDays,
CheckCircle2,
ChevronRight,
ClipboardList,
FileText,
FilterIcon,
MapPin,
Phone,
SlidersHorizontal,
Wallet,
} from "lucide-vue-next";
import { fmtDateDMY } from "@/Utilities/functions";
const props = defineProps({
pendingJobs: { type: Object, default: null },
processedJobs: { type: Object, default: null },
completedJobs: { type: Object, default: null },
jobs: { type: Object, required: true },
clients: { type: Array, default: () => [] },
view_mode: { type: String, default: "assigned" },
filters: { type: Object, default: () => ({ search: "", client: "" }) },
});
const isCompleted = computed(() => props.view_mode === "completed-today");
// Filters
const search = ref(props.filters.search || "");
const clientFilter = ref(props.filters.client || "all");
const isFiltering = ref(false);
const showFilters = ref(
!!(props.filters.search || (props.filters.client && props.filters.client !== "all"))
const clientFilter = ref(props.filters.client || "");
const isLoading = ref(false);
const listNonActivity = computed(() =>
(props.jobs.data || []).filter((item) => !item.added_activity)
);
const debouncedFilter = useDebounceFn(() => performFilter(), 500);
watch(search, () => debouncedFilter());
watch(clientFilter, () => performFilter());
const listActivity = computed(() =>
(props.jobs.data || []).filter((item) => !!item.added_activity)
);
function performFilter() {
isFiltering.value = true;
const targetRoute = isCompleted.value ? "phone.completed" : "phone.index";
const only = isCompleted.value
? ["completedJobs", "filters"]
: ["pendingJobs", "processedJobs", "filters"];
const debouncedSearch = useDebounceFn((value) => {
performSearch();
}, 500);
watch(search, (newValue) => {
debouncedSearch(newValue);
});
watch(clientFilter, () => {
performSearch();
});
function performSearch() {
isLoading.value = true;
router.get(
route(targetRoute),
route(props.view_mode === "completed-today" ? "phone.completed" : "phone.index"),
{
search: search.value || undefined,
client: clientFilter.value !== "all" ? clientFilter.value : undefined,
client: clientFilter.value || undefined,
},
{
preserveState: true,
preserveScroll: false,
only,
reset: isCompleted.value
? ["completedJobs"]
: ["pendingJobs", "processedJobs"],
onSuccess: () => {
isFiltering.value = false;
},
onError: () => {
isFiltering.value = false;
preserveScroll: true,
only: ["jobs", "filters"],
onFinish: () => {
isLoading.value = false;
},
}
);
}
function clearFilters() {
function clearSearch() {
search.value = "";
clientFilter.value = "all";
clientFilter.value = "";
}
// Scroll-hide title
const scrolled = ref(false);
let stopNavigateListener = null;
function onScroll() {
if (!scrolled.value && window.scrollY > 50) {
scrolled.value = true;
} else if (scrolled.value && window.scrollY < 10) {
scrolled.value = false;
function formatDateDMY(d) {
if (!d) return "-";
if (/^\d{4}-\d{2}-\d{2}/.test(d)) {
const [y, m, rest] = d.split("-");
const day = (rest || "").slice(0, 2) || "01";
return `${day}.${m}.${y}`;
}
const dt = new Date(d);
if (Number.isNaN(dt.getTime())) return String(d);
const dd = String(dt.getDate()).padStart(2, "0");
const mm = String(dt.getMonth() + 1).padStart(2, "0");
const yyyy = dt.getFullYear();
return `${dd}.${mm}.${yyyy}`;
}
onMounted(() => {
let trackedPath = window.location.pathname;
stopNavigateListener = router.on("navigate", (event) => {
const newPath = new URL(event.detail.page.url, window.location.origin).pathname;
if (newPath !== trackedPath) {
scrolled.value = false;
trackedPath = newPath;
}
});
window.addEventListener("scroll", onScroll, { passive: true });
});
onUnmounted(() => {
if (stopNavigateListener) stopNavigateListener();
window.removeEventListener("scroll", onScroll);
});
// Counts
const pendingCount = computed(() => props.pendingJobs?.total ?? 0);
const processedCount = computed(() => props.processedJobs?.total ?? 0);
// Helpers
function formatAmount(val) {
if (val === null || val === undefined) return "0,00";
const num = typeof val === "number" ? val : parseFloat(val);
@ -141,247 +118,58 @@ function getCaseUuid(job) {
);
}
function jobHref(job) {
const uuid = getCaseUuid(job);
if (!uuid) return null;
return route("phone.case", {
client_case: uuid,
completed: isCompleted.value ? 1 : undefined,
});
}
// JobCard component
const JobCard = defineComponent({
name: "JobCard",
props: {
job: { type: Object, required: true },
href: { type: String, default: null },
accentClass: { type: String, default: "border-l-blue-500" },
showLastActivity: { type: Boolean, default: false },
},
setup(p) {
return () => {
const j = p.job;
const person = j.contract?.client_case?.person;
const clientName = j.contract?.client_case?.client?.person?.full_name;
const address = person?.address?.address;
const phone = person?.phones?.[0]?.nu;
const balance = j.contract?.account?.balance_amount;
const inner = h("div", { class: `border-l-4 ${p.accentClass}` }, [
h(
"div",
{
class: "px-4 pt-4 pb-2 flex items-start justify-between gap-3",
},
[
h("div", { class: "flex-1 min-w-0" }, [
h(
"p",
{
class:
"font-bold text-base text-gray-900 dark:text-gray-100 truncate leading-tight",
},
person?.full_name || "—"
),
h(
"p",
{
class: "text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate",
},
j.contract?.reference || j.contract?.uuid || "—"
),
clientName
? h(
"p",
{
class:
"text-xs text-indigo-600 dark:text-indigo-400 mt-0.5 truncate",
},
clientName
)
: null,
]),
j.priority
? h(
"span",
{
class:
"shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400 animate-pulse",
},
"Prioriteta"
)
: null,
]
),
address || phone
? h("div", { class: "px-4 pb-3 space-y-1.5" }, [
address
? h(
"div",
{
class: "flex items-center gap-2 text-gray-500 dark:text-gray-400",
},
[
h(MapPin, {
class: "w-3.5 h-3.5 shrink-0 text-gray-400",
}),
h("span", { class: "text-xs truncate" }, address),
]
)
: null,
phone
? h(
"div",
{
class: "flex items-center gap-2 text-gray-500 dark:text-gray-400",
},
[
h(Phone, {
class: "w-3.5 h-3.5 shrink-0 text-gray-400",
}),
h("span", { class: "text-xs font-medium" }, phone),
]
)
: null,
])
: null,
balance != null
? h(
"div",
{
class:
"mx-4 mb-3 px-3 py-2 bg-red-50 dark:bg-red-950/20 rounded-lg flex items-center gap-2 border border-red-100 dark:border-red-900",
},
[
h(Wallet, {
class: "w-4 h-4 text-red-500 shrink-0",
}),
h(
"span",
{
class: "font-bold text-red-600 dark:text-red-400 text-sm",
},
`${formatAmount(balance)}`
),
h("span", { class: "text-xs text-red-400" }, "odprto"),
]
)
: null,
h(
"div",
{
class:
"px-4 py-3 border-t bg-gray-50/60 dark:bg-gray-900/40 flex items-center justify-between",
},
[
h(
"div",
{
class: "flex items-center gap-1.5 text-xs text-gray-400",
},
[
h(CalendarDays, { class: "w-3.5 h-3.5" }),
h(
"span",
function changePage(url) {
if (!url) return;
isLoading.value = true;
router.get(
url,
{},
p.showLastActivity && j.last_activity
? fmtDateDMY(j.last_activity)
: fmtDateDMY(j.assigned_at)
),
]
),
p.href
? h(
"div",
{
class: "flex items-center gap-0.5 text-primary font-semibold text-sm",
preserveState: true,
preserveScroll: false,
only: ["jobs"],
onFinish: () => {
isLoading.value = false;
},
["Odpri", h(ChevronRight, { class: "w-4 h-4" })]
)
: h("span", { class: "text-xs text-gray-400 italic" }, "Manjka primer"),
]
),
]);
return p.href
? h(
"a",
{
href: p.href,
class:
"block rounded-xl border shadow-sm overflow-hidden bg-white dark:bg-card active:scale-[0.99] transition-transform duration-100",
},
inner
)
: h(
"div",
{
class:
"rounded-xl border shadow-sm overflow-hidden bg-white dark:bg-card opacity-60",
},
inner
}
);
};
},
});
}
</script>
<template>
<AppPhoneLayout title="Phone">
<template #header>
<h2
class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight overflow-hidden transition-all duration-200 ease-in-out"
:class="scrolled ? 'max-h-0 opacity-0 mb-0' : 'max-h-12 opacity-100 mb-0'"
>
{{ isCompleted ? "Zaključeno danes" : "Terenska opravila" }}
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{
props.view_mode === "completed-today" ? "Zaključeno danes" : "Terenska opravila"
}}
</h2>
</template>
<!-- Filter bar -->
<div class="pt-2 space-y-2">
<div class="flex items-center gap-2">
<div class="relative flex-1">
<div class="py-6 sm:py-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 space-y-4">
<!-- Filters Section -->
<Card>
<CardHeader class="flex flex-row gap-1 items-center">
<FilterIcon size="20" />
<CardTitle class="text-xl">Filter</CardTitle>
</CardHeader>
<CardContent>
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<Input
v-model="search"
type="text"
placeholder="Išči po referenci ali imenu..."
class="pr-10"
class="w-full"
/>
<span
v-if="isFiltering"
class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"
/>
</div>
<Button
variant="outline"
size="icon"
class="shrink-0"
:class="
showFilters || clientFilter !== 'all' ? 'bg-primary/10 border-primary' : ''
"
title="Filter po naročniku"
@click="showFilters = !showFilters"
>
<SlidersHorizontal class="w-4 h-4" />
</Button>
<Button
v-if="search || clientFilter !== 'all'"
variant="ghost"
size="sm"
class="shrink-0 text-muted-foreground"
@click="clearFilters"
>
Počisti
</Button>
</div>
<div v-if="showFilters || clientFilter !== 'all'">
<Select v-model="clientFilter">
<SelectTrigger class="w-full">
<SelectTrigger class="w-full sm:w-64">
<SelectValue placeholder="Vsi naročniki" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Vsi naročniki</SelectItem>
<SelectItem
v-for="client in props.clients"
:key="client.uuid"
@ -391,140 +179,405 @@ const JobCard = defineComponent({
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</template>
<div class="pb-8">
<!-- Assigned mode: two tabs (Novo / Obdelano) -->
<div v-if="!isCompleted" class="px-4 pt-4">
<Tabs default-value="pending" class="w-full">
<TabsList class="w-full grid grid-cols-2 mb-4">
<TabsTrigger value="pending">
<span class="inline-flex flex-row items-center gap-1">
<ClipboardList class="w-3.5 h-3.5 shrink-0" />
Novo
<Badge v-if="pendingCount" variant="secondary" class="h-4 px-1 text-xs">
{{ pendingCount }}
<Button
v-if="search || clientFilter"
variant="outline"
@click="clearSearch"
>
Počisti
</Button>
</div>
</CardContent>
</Card>
<!-- Loading Skeleton -->
<div v-if="isLoading" class="space-y-6">
<div class="space-y-3">
<Skeleton class="h-8 w-48" />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton v-for="i in 6" :key="i" class="h-72" />
</div>
</div>
</div>
<!-- Content -->
<div v-else-if="props.jobs.data && props.jobs.data.length" class="space-y-8">
<!-- Non-Activity Jobs -->
<section v-if="listNonActivity.length" class="space-y-4">
<div class="flex items-center gap-3">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
Nove / Ne obdelano
</h2>
<Badge variant="secondary" class="text-sm">
{{ listNonActivity.length }}
</Badge>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
<Card
v-for="job in listNonActivity"
:key="job.id"
class="flex flex-col hover:shadow-xl transition-all duration-200 border-l-2 border-l-blue-500"
>
<CardHeader>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<h3
class="text-xl font-bold text-gray-900 dark:text-gray-100 truncate mb-1"
>
{{ job.contract?.client_case?.person?.full_name || "—" }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
<span class="font-semibold text-indigo-600 dark:text-indigo-400"
>Naročnik:</span
>
<span class="ml-1 font-medium text-gray-900 dark:text-gray-200">
{{
job.contract?.client_case?.client?.person?.full_name || "—"
}}
</span>
</TabsTrigger>
<TabsTrigger value="processed">
<span class="inline-flex flex-row items-center gap-1">
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
Obdelano
<Badge v-if="processedCount" variant="secondary" class="h-4 px-1 text-xs">
{{ processedCount }}
</p>
</div>
<Badge
v-if="job.priority"
variant="destructive"
class="shrink-0 animate-pulse"
>
Prioriteta
</Badge>
</div>
</CardHeader>
<CardContent class="flex-1 space-y-3 pt-4">
<div class="space-y-3 text-sm">
<div class="flex items-start gap-3">
<CalendarDays class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<p
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
>
Dodeljeno
</p>
<p class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatDateDMY(job.assigned_at) }}
</p>
</div>
</div>
<Separator />
<div class="flex items-start gap-3">
<FileText class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<p
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
>
Pogodba
</p>
<p
class="font-mono text-xs text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded inline-block"
>
{{ job.contract?.reference || job.contract?.uuid }}
</p>
</div>
</div>
<div class="flex items-start gap-3">
<MapPin class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<p
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
>
Naslov
</p>
<p class="text-gray-900 dark:text-gray-100">
{{ job.contract?.client_case?.person?.address?.address || "—" }}
</p>
</div>
</div>
<div class="flex items-start gap-3">
<Phone class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<p
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
>
Telefon
</p>
<p class="font-medium text-gray-900 dark:text-gray-100">
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || "—" }}
</p>
</div>
</div>
<div
v-if="job.contract?.account?.balance_amount != null"
class="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 mt-4"
>
<Wallet
class="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5"
/>
<div class="flex-1 min-w-0">
<p
class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide mb-0.5"
>
Odprto
</p>
<p class="font-bold text-red-700 dark:text-red-400 text-lg">
{{ formatAmount(job.contract.account.balance_amount) }}
</p>
</div>
</div>
</div>
</CardContent>
<CardFooter class="pt-4 mt-auto">
<Button
v-if="getCaseUuid(job)"
as="a"
:href="
route('phone.case', {
client_case: getCaseUuid(job),
completed: props.view_mode === 'completed-today' ? 1 : undefined,
})
"
class="w-full font-semibold"
size="lg"
>
Odpri primer
</Button>
<Button v-else disabled class="w-full" variant="secondary" size="lg">
Manjka primer
</Button>
</CardFooter>
</Card>
</div>
</section>
<!-- Activity Jobs -->
<section v-if="listActivity.length" class="space-y-4">
<div class="flex items-center gap-3">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
Obdelane pogodbe
</h2>
<Badge variant="secondary" class="text-sm">
{{ listActivity.length }}
</Badge>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card
v-for="job in listActivity"
:key="job.id"
class="flex flex-col hover:shadow-xl transition-all duration-200 border-l-2 border-l-green-500"
>
<CardHeader>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<h3
class="text-xl font-bold text-gray-900 dark:text-gray-100 truncate mb-1"
>
{{ job.contract?.client_case?.person?.full_name || "—" }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
<span class="font-semibold text-indigo-600 dark:text-indigo-400"
>Naročnik:</span
>
<span class="ml-1 font-medium text-gray-900 dark:text-gray-200">
{{
job.contract?.client_case?.client?.person?.full_name || "—"
}}
</span>
</TabsTrigger>
</TabsList>
<!-- Pending tab -->
<TabsContent value="pending" class="space-y-3">
<InfiniteScroll data="pendingJobs" only-next>
<template #default="{ loading }">
<template v-if="props.pendingJobs?.data?.length">
<JobCard
v-for="job in props.pendingJobs.data"
:key="job.id"
:job="job"
:href="jobHref(job)"
accent-class="border-l-blue-500"
/>
</template>
<div
v-else-if="!loading"
class="py-16 text-center text-gray-500 dark:text-gray-400 space-y-2"
</p>
</div>
<Badge
v-if="job.priority"
variant="destructive"
class="shrink-0 animate-pulse"
>
<ClipboardList class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto" />
<p class="text-sm">
Prioriteta
</Badge>
</div>
</CardHeader>
<CardContent class="flex-1 space-y-3 pt-4">
<div class="space-y-3 text-sm">
<div class="flex items-start gap-3">
<CalendarDays class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<p
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
>
Dodeljeno
</p>
<p class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatDateDMY(job.assigned_at) }}
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-2 bg-green-50 dark:bg-green-900/20 rounded"
>
<CalendarDays
class="w-5 h-5 text-green-600 dark:text-green-400 shrink-0 mt-0.5"
/>
<div class="flex-1 min-w-0">
<p
class="text-xs text-green-600 dark:text-green-400 uppercase tracking-wide mb-0.5"
>
Zadnja aktivnost
</p>
<p class="font-semibold text-green-700 dark:text-green-400">
{{ formatDateDMY(job.last_activity) || "—" }}
</p>
</div>
</div>
<Separator />
<div class="flex items-start gap-3">
<FileText class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<p
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
>
Kontrakt
</p>
<p
class="font-mono text-xs text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded inline-block"
>
{{ job.contract?.reference || job.contract?.uuid }}
</p>
</div>
</div>
<div class="flex items-start gap-3">
<MapPin class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<p
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
>
Naslov
</p>
<p class="text-gray-900 dark:text-gray-100">
{{ job.contract?.client_case?.person?.address?.address || "—" }}
</p>
</div>
</div>
<div class="flex items-start gap-3">
<Phone class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<p
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
>
Telefon
</p>
<p class="font-medium text-gray-900 dark:text-gray-100">
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || "—" }}
</p>
</div>
</div>
<div
v-if="job.contract?.account?.balance_amount != null"
class="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 mt-4"
>
<Wallet
class="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5"
/>
<div class="flex-1 min-w-0">
<p
class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide mb-0.5"
>
Odprto
</p>
<p class="font-bold text-red-700 dark:text-red-400 text-lg">
{{ formatAmount(job.contract.account.balance_amount) }}
</p>
</div>
</div>
</div>
</CardContent>
<CardFooter class="pt-4 mt-auto">
<Button
v-if="getCaseUuid(job)"
as="a"
:href="
route('phone.case', {
client_case: getCaseUuid(job),
completed: props.view_mode === 'completed-today' ? 1 : undefined,
})
"
class="w-full font-semibold"
variant="secondary"
size="lg"
>
Odpri primer
</Button>
<Button v-else disabled class="w-full" variant="secondary" size="lg">
Manjka primer
</Button>
</CardFooter>
</Card>
</div>
</section>
<!-- Pagination -->
<Card v-if="props.jobs.links && props.jobs.links.length > 3">
<CardContent class="pt-6">
<div class="flex flex-wrap items-center justify-center gap-2">
<Button
v-for="(link, index) in props.jobs.links"
:key="index"
:variant="link.active ? 'default' : 'outline'"
:disabled="!link.url"
@click="changePage(link.url)"
size="sm"
v-html="link.label"
/>
</div>
</CardContent>
</Card>
</div>
<!-- Empty State -->
<Card v-else>
<CardContent class="py-12">
<div class="text-center space-y-3">
<div
class="mx-auto w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center"
>
<svg
class="w-8 h-8 text-gray-400 dark:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</div>
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ search || clientFilter ? "Ni zadetkov" : "Ni opravil" }}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{
search || clientFilter !== "all" ? "Ni zadetkov" : "Ni novih opravil"
search || clientFilter
? "Poskusite spremeniti iskalne kriterije"
: "Trenutno nimate dodeljenih terenskih opravil"
}}
</p>
</div>
</template>
<template #loading>
<div class="space-y-3">
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
</div>
</template>
</InfiniteScroll>
</TabsContent>
<!-- Processed tab -->
<TabsContent value="processed" class="space-y-3">
<InfiniteScroll data="processedJobs" only-next>
<template #default="{ loading }">
<template v-if="props.processedJobs?.data?.length">
<JobCard
v-for="job in props.processedJobs.data"
:key="job.id"
:job="job"
:href="jobHref(job)"
accent-class="border-l-green-500"
:show-last-activity="true"
/>
</template>
<div
v-else-if="!loading"
class="py-16 text-center text-gray-500 dark:text-gray-400 space-y-2"
>
<CheckCircle2 class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto" />
<p class="text-sm">
{{
search || clientFilter !== "all"
? "Ni zadetkov"
: "Ni obdelanih opravil"
}}
</p>
</div>
</template>
<template #loading>
<div class="space-y-3">
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
</div>
</template>
</InfiniteScroll>
</TabsContent>
</Tabs>
</div>
<!-- Completed-today mode: single scroll list -->
<div v-else class="px-4 pt-4 space-y-3">
<InfiniteScroll data="completedJobs" only-next>
<template #default="{ loading }">
<template v-if="props.completedJobs?.data?.length">
<JobCard
v-for="job in props.completedJobs.data"
:key="job.id"
:job="job"
:href="jobHref(job)"
accent-class="border-l-purple-500"
:show-last-activity="true"
/>
</template>
<div
v-else-if="!loading"
class="py-16 text-center text-gray-500 dark:text-gray-400 space-y-2"
>
<CheckCircle2 class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto" />
<p class="text-sm">
{{
search || clientFilter !== "all"
? "Ni zadetkov"
: "Danes ni zaključenih opravil"
}}
</p>
</div>
</template>
<template #loading>
<div class="space-y-3">
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
</div>
</template>
</InfiniteScroll>
</CardContent>
</Card>
</div>
</div>
</AppPhoneLayout>

View File

@ -1,159 +0,0 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { useForm } from "@inertiajs/vue3";
import { computed, watch } from "vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { FileText } from "lucide-vue-next";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import InputLabel from "@/Components/InputLabel.vue";
import { Checkbox } from "@/Components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({
setting: Object,
decisions: Array,
actions: Array,
});
const form = useForm({
create_activity_on_balance_change: !!props.setting?.create_activity_on_balance_change,
default_action_id: props.setting?.default_action_id ?? null,
default_decision_id: props.setting?.default_decision_id ?? null,
activity_note_template:
props.setting?.activity_note_template ??
"Sprememba stanja pogodbe: {old_balance} → {new_balance} {currency}",
});
const filteredDecisions = computed(() => {
const actionId = form.default_action_id;
if (!actionId) return [];
const action = props.actions?.find((a) => a.id === actionId);
if (!action || !action.decision_ids) return [];
const ids = new Set(action.decision_ids);
return (props.decisions || []).filter((d) => ids.has(d.id));
});
watch(
() => form.default_action_id,
(newVal) => {
if (!newVal) {
form.default_decision_id = null;
} else {
const ids = new Set((filteredDecisions.value || []).map((d) => d.id));
if (!ids.has(form.default_decision_id)) {
form.default_decision_id = null;
}
}
}
);
const submit = () => {
form.put(route("settings.contract.update"), {
preserveScroll: true,
});
};
</script>
<template>
<AppLayout title="Nastavitve pogodb">
<template #header></template>
<div class="max-w-3xl mx-auto p-6">
<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">
<FileText :size="18" />
<CardTitle class="uppercase">Nastavitve pogodb</CardTitle>
</div>
</template>
<div class="space-y-6 p-4 border-t">
<div class="flex items-center gap-2">
<Checkbox
id="create-activity"
v-model="form.create_activity_on_balance_change"
/>
<InputLabel for="create-activity" class="text-sm font-normal cursor-pointer">
Ustvari aktivnost ob spremembi odprtega zneska pogodbe
</InputLabel>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<InputLabel for="default-action">Privzeto dejanje</InputLabel>
<Select v-model="form.default_action_id">
<SelectTrigger id="default-action">
<SelectValue placeholder="— Brez —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Brez </SelectItem>
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">{{
a.name
}}</SelectItem>
</SelectContent>
</Select>
<div v-if="form.errors.default_action_id" class="text-sm text-red-600 mt-1">
{{ form.errors.default_action_id }}
</div>
</div>
<div>
<InputLabel for="default-decision">Privzeta odločitev</InputLabel>
<Select
v-model="form.default_decision_id"
:disabled="!form.default_action_id"
>
<SelectTrigger id="default-decision">
<SelectValue placeholder="— Najprej izberite dejanje —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Najprej izberite dejanje </SelectItem>
<SelectItem v-for="d in filteredDecisions" :key="d.id" :value="d.id">{{
d.name
}}</SelectItem>
</SelectContent>
</Select>
<div
v-if="form.errors.default_decision_id"
class="text-sm text-red-600 mt-1"
>
{{ form.errors.default_decision_id }}
</div>
</div>
</div>
<div>
<InputLabel for="note-template">Predloga opombe aktivnosti</InputLabel>
<Input id="note-template" v-model="form.activity_note_template" />
<p class="text-xs text-gray-500 mt-1">
Podprti žetoni: {old_balance}, {new_balance}, {currency}
</p>
<div
v-if="form.errors.activity_note_template"
class="text-sm text-red-600 mt-1"
>
{{ form.errors.activity_note_template }}
</div>
</div>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="form.reset()">Ponastavi</Button>
<Button @click="submit" :disabled="form.processing">Shrani</Button>
</div>
</div>
</AppCard>
</div>
</AppLayout>
</template>

View File

@ -1,12 +1,6 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
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 { Link } from "@inertiajs/vue3";
import {
@ -18,62 +12,48 @@ import {
Archive,
ArrowRight,
BarChart3,
CalendarDays,
} from "lucide-vue-next";
const settingsCards = [
{
title: "Segmenti",
description: "Upravljanje segmentov, ki se uporabljajo v aplikaciji.",
title: "Segments",
description: "Manage segments used across the app.",
route: "settings.segments",
icon: Layers,
},
{
title: "Plačila",
description: "Privzete nastavitve za plačila in samodejne aktivnosti.",
title: "Payments",
description: "Defaults for payments and auto-activity.",
route: "settings.payment.edit",
icon: CreditCard,
},
{
title: "Obroki",
description: "Privzete nastavitve za obroke in samodejne aktivnosti.",
route: "settings.installment.edit",
icon: CalendarDays,
},
{
title: "Potek dela",
description: "Konfiguracija akcij in odločitev.",
title: "Workflow",
description: "Configure actions and decisions relationships.",
route: "settings.workflow",
icon: GitBranch,
},
{
title: "Nastavitve terenskega dela",
description: "Konfiguracija pravil terenskega dela po segmentih.",
title: "Field Job Settings",
description: "Configure segment-based field job rules.",
route: "settings.fieldjob.index",
icon: Briefcase,
},
{
title: "Konfiguracije pogodb",
description: "Samodejna dodelitev začetnih segmentov pogodbam glede na vrsto.",
title: "Contract Configs",
description: "Auto-assign initial segments for contracts by type.",
route: "settings.contractConfigs.index",
icon: FileText,
},
{
title: "Nastavitve pogodb",
description: "Sprožilci samodejnih aktivnosti ob spremembi stanja pogodbe.",
route: "settings.contract.edit",
icon: FileText,
},
{
title: "Nastavitve arhiviranja",
description: "Določite pravila za arhiviranje ali mehko brisanje starih podatkov.",
title: "Archive Settings",
description: "Define rules for archiving or soft-deleting aged data.",
route: "settings.archive.index",
icon: Archive,
},
{
title: "Poročila",
description:
"Konfiguracija poročil na podlagi podatkovne baze z dinamičnimi poizvedbami.",
title: "Reports",
description: "Configure database-driven reports with dynamic queries.",
route: "settings.reports.index",
icon: BarChart3,
},
@ -81,14 +61,14 @@ const settingsCards = [
</script>
<template>
<AppLayout title="Nastavitve">
<AppLayout title="Settings">
<template #header />
<div class="pt-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Nastavitve</h1>
<h1 class="text-3xl font-bold text-gray-900">Settings</h1>
<p class="mt-2 text-sm text-muted-foreground">
Upravljanje konfiguracije in nastavitev aplikacije
Manage your application configuration and preferences
</p>
</div>
@ -112,7 +92,7 @@ const settingsCards = [
<CardContent>
<Link :href="route(card.route)">
<Button class="w-full group">
Odpri nastavitve
Open Settings
<ArrowRight
class="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1"
/>

View File

@ -1,167 +0,0 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { useForm } from "@inertiajs/vue3";
import { computed, watch } from "vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { CalendarDays } from "lucide-vue-next";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import InputLabel from "@/Components/InputLabel.vue";
import { Checkbox } from "@/Components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({
setting: Object,
decisions: Array,
actions: Array,
});
const form = useForm({
default_currency: props.setting?.default_currency ?? "EUR",
create_activity_on_installment: !!props.setting?.create_activity_on_installment,
default_action_id: props.setting?.default_action_id ?? null,
default_decision_id: props.setting?.default_decision_id ?? null,
activity_note_template:
props.setting?.activity_note_template ?? "Dodan obrok: {amount} {currency}",
});
const filteredDecisions = computed(() => {
const actionId = form.default_action_id;
if (!actionId) return [];
const action = props.actions?.find((a) => a.id === actionId);
if (!action || !action.decision_ids) return [];
const ids = new Set(action.decision_ids);
return (props.decisions || []).filter((d) => ids.has(d.id));
});
watch(
() => form.default_action_id,
(newVal) => {
if (!newVal) {
form.default_decision_id = null;
} else {
const ids = new Set((filteredDecisions.value || []).map((d) => d.id));
if (!ids.has(form.default_decision_id)) {
form.default_decision_id = null;
}
}
}
);
const submit = () => {
form.put(route("settings.installment.update"), {
preserveScroll: true,
});
};
</script>
<template>
<AppLayout title="Nastavitve obrokov">
<template #header></template>
<div class="max-w-3xl mx-auto p-6">
<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">
<CalendarDays :size="18" />
<CardTitle class="uppercase">Nastavitve obrokov</CardTitle>
</div>
</template>
<div class="space-y-6 p-4 border-t">
<div>
<InputLabel for="currency">Privzeta valuta</InputLabel>
<Input
id="currency"
v-model="form.default_currency"
maxlength="3"
class="w-40"
/>
<div v-if="form.errors.default_currency" class="text-sm text-red-600 mt-1">
{{ form.errors.default_currency }}
</div>
</div>
<div class="flex items-center gap-2">
<Checkbox id="create-activity" v-model="form.create_activity_on_installment" />
<InputLabel for="create-activity" class="text-sm font-normal cursor-pointer">
Ustvari aktivnost ob dodanem obroku
</InputLabel>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<InputLabel for="default-action">Privzeto dejanje</InputLabel>
<Select v-model="form.default_action_id">
<SelectTrigger id="default-action">
<SelectValue placeholder="— Brez —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Brez </SelectItem>
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">{{
a.name
}}</SelectItem>
</SelectContent>
</Select>
<div v-if="form.errors.default_action_id" class="text-sm text-red-600 mt-1">
{{ form.errors.default_action_id }}
</div>
</div>
<div>
<InputLabel for="default-decision">Privzeta odločitev</InputLabel>
<Select
v-model="form.default_decision_id"
:disabled="!form.default_action_id"
>
<SelectTrigger id="default-decision">
<SelectValue placeholder="— Najprej izberite dejanje —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"> Najprej izberite dejanje </SelectItem>
<SelectItem v-for="d in filteredDecisions" :key="d.id" :value="d.id">{{
d.name
}}</SelectItem>
</SelectContent>
</Select>
<div
v-if="form.errors.default_decision_id"
class="text-sm text-red-600 mt-1"
>
{{ form.errors.default_decision_id }}
</div>
</div>
</div>
<div>
<InputLabel for="note-template">Predloga opombe aktivnosti</InputLabel>
<Input id="note-template" v-model="form.activity_note_template" />
<p class="text-xs text-gray-500 mt-1">Podprti žetoni: {amount}, {currency}</p>
<div
v-if="form.errors.activity_note_template"
class="text-sm text-red-600 mt-1"
>
{{ form.errors.activity_note_template }}
</div>
</div>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="form.reset()">Ponastavi</Button>
<Button @click="submit" :disabled="form.processing">Shrani</Button>
</div>
</div>
</AppCard>
</div>
</AppLayout>
</template>

View File

@ -15,14 +15,12 @@ const props = defineProps({
email_templates: { type: Array, default: () => [] },
events: { type: Array, default: () => [] },
archive_settings: { type: Array, default: () => [] },
condition_fields: { type: Array, default: () => [] },
condition_operators: { type: Object, default: () => ({}) },
});
const activeTab = ref("actions");
</script>
<template>
<AppLayout title="Potek dela">
<AppLayout title="Workflow">
<template #header></template>
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@ -36,7 +34,7 @@ const activeTab = ref("actions");
<template #header>
<div class="flex items-center gap-2">
<Workflow :size="18" />
<CardTitle class="uppercase">Potek dela</CardTitle>
<CardTitle class="uppercase">Workflow</CardTitle>
</div>
</template>
<Tabs v-model="activeTab" class="border-t">
@ -59,8 +57,6 @@ const activeTab = ref("actions");
:available-events="events"
:segments="segments"
:archive-settings="archive_settings"
:condition-fields="condition_fields"
:condition-operators="condition_operators"
/>
</TabsContent>
</Tabs>

View File

@ -1,4 +1,5 @@
<script setup>
// flowbite-vue table imports removed; using DataTableClient
import {
Dialog,
DialogContent,
@ -26,7 +27,7 @@ import { Input } from "@/Components/ui/input";
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import { Button } from "@/Components/ui/button";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { FilterIcon, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
@ -59,13 +60,16 @@ const segmentOptions = computed(() =>
}))
);
// DataTable state
const sort = ref({ key: null, direction: null });
const page = ref(1);
const pageSize = ref(25);
const columns = [
{ key: "id", label: "#", sortable: true, class: "w-16" },
{ key: "name", label: "Ime", sortable: true },
{ key: "color_tag", label: "Barva", sortable: false },
{ key: "segment", label: "Segment", sortable: false },
{ key: "decisions", label: "Odločitve", sortable: false, class: "w-32" },
{ key: "actions", label: "", sortable: false, class: "w-12" },
];
const form = useForm({
@ -227,12 +231,18 @@ const destroyAction = () => {
<Button @click="openCreateDrawer">+ Dodaj akcijo</Button>
</div>
<div>
<DataTableNew2
<DataTableClient
:columns="columns"
:data="filtered"
:pageSize="25"
:rows="filtered"
:sort="sort"
:search="''"
:page="page"
:pageSize="pageSize"
:showToolbar="false"
:showPagination="true"
@update:sort="(v) => (sort = v)"
@update:page="(v) => (page = v)"
@update:pageSize="(v) => (pageSize = v)"
>
<template #cell-color_tag="{ row }">
<div class="flex items-center gap-2">
@ -252,7 +262,7 @@ const destroyAction = () => {
{{ row.segment?.name || "" }}
</span>
</template>
<template #cell-actions="{ row }">
<template #actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
@ -275,7 +285,7 @@ const destroyAction = () => {
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTableNew2>
</DataTableClient>
</div>
<Dialog v-model:open="drawerEdit">
@ -295,7 +305,7 @@ const destroyAction = () => {
</div>
</div>
<div class="space-y-1.5">
<div>
<InputLabel for="segmentEdit">Segment</InputLabel>
<AppCombobox
id="segmentEdit"
@ -313,7 +323,7 @@ const destroyAction = () => {
v-model="form.decisions"
:items="selectOptions"
placeholder="Dodaj odločitev"
chip-variant="secondary"
content-class="p-0 w-full"
/>
</div>
@ -363,7 +373,7 @@ const destroyAction = () => {
v-model="createForm.decisions"
:items="selectOptions"
placeholder="Dodaj odločitev"
chip-variant="secondary"
content-class="p-0 w-full"
/>
</div>

View File

@ -1,5 +1,6 @@
<script setup>
import { DottedMenu } from "@/Utilities/Icons";
// flowbite-vue table imports removed; using DataTableClient
import { EditIcon, TrashBinIcon, DottedMenu } from "@/Utilities/Icons";
import {
Dialog,
DialogContent,
@ -29,20 +30,11 @@ import {
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import { Button } from "@/Components/ui/button";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
import Dropdown from "@/Components/Dropdown.vue";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import {
FilterIcon,
MoreHorizontal,
Pencil,
Plus,
Trash,
Trash2,
X,
} from "lucide-vue-next";
import { Switch } from "@/Components/ui/switch";
import { FilterIcon, Trash2, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
import {
DropdownMenu,
DropdownMenuContent,
@ -57,8 +49,6 @@ const props = defineProps({
availableEvents: { type: Array, default: () => [] },
segments: { type: Array, default: () => [] },
archiveSettings: { type: Array, default: () => [] },
conditionFields: { type: Array, default: () => [] },
conditionOperators: { type: Object, default: () => ({}) },
});
const drawerEdit = ref(false);
@ -74,6 +64,10 @@ const selectedEvents = ref([]);
const actionOptions = ref([]);
// DataTable state
const sort = ref({ key: null, direction: null });
const page = ref(1);
const pageSize = ref(25);
const columns = [
{ key: "id", label: "#", sortable: true },
{ key: "name", label: "Ime", sortable: true },
@ -81,7 +75,6 @@ const columns = [
{ key: "events", label: "Dogodki", sortable: false },
{ key: "belongs", label: "Pripada akcijam", sortable: false },
{ key: "auto_mail", label: "Auto mail", sortable: false },
{ key: "actions", label: "", sortable: false, class: "w-12" },
];
const form = useForm({
@ -198,8 +191,6 @@ function defaultConfigForKey(key) {
return { archive_setting_id: null, reactivate: false };
case "end_field_job":
return {};
case "add_call_later":
return {};
default:
return {};
}
@ -234,39 +225,6 @@ function defaultEventPayload() {
};
}
function operatorsForField(fieldKey) {
const field = (props.conditionFields || []).find((f) => f.key === fieldKey);
if (!field) {
return props.conditionOperators?.numeric ?? [];
}
return props.conditionOperators?.[field.type] ?? [];
}
function addCondition(ev) {
if (!Array.isArray(ev.config.conditions)) {
ev.config.conditions = [];
}
const firstField = (props.conditionFields || [])[0];
const firstOperator = firstField
? operatorsForField(firstField.key)[0]?.key ?? "="
: "=";
ev.config.conditions.push({
field: firstField?.key ?? "",
operator: firstOperator,
value: "",
});
}
function removeCondition(ev, idx) {
ev.config.conditions.splice(idx, 1);
}
function onConditionFieldChange(condition) {
const ops = operatorsForField(condition.field);
condition.operator = ops[0]?.key ?? "=";
condition.value = "";
}
function tryAdoptRaw(ev) {
try {
const obj = JSON.parse(ev.__rawJson || "{}");
@ -508,12 +466,18 @@ const destroyDecision = () => {
</div>
</div>
<div>
<DataTableNew2
<DataTableClient
:columns="columns"
:data="filtered"
:pageSize="25"
:rows="filtered"
:sort="sort"
:search="''"
:page="page"
:pageSize="pageSize"
:showToolbar="false"
:showPagination="true"
@update:sort="(v) => (sort = v)"
@update:page="(v) => (page = v)"
@update:pageSize="(v) => (pageSize = v)"
>
<template #cell-color_tag="{ row }">
<div class="flex items-center gap-2">
@ -530,13 +494,14 @@ const destroyDecision = () => {
</template>
<template #cell-events="{ row }">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">{{ row.events?.length ?? 0 }}</span>
<Dropdown align="left" width="64" :close-on-content-click="false">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200"
>
{{ row.events?.length ?? 0 }}
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
@ -576,7 +541,7 @@ const destroyDecision = () => {
<template #cell-auto_mail="{ row }">
<div class="flex flex-col text-sm">
<span :class="row.auto_mail ? 'text-green-700' : 'text-gray-500'">{{
row.auto_mail ? "Vključeno" : "Izključeno"
row.auto_mail ? "Enabled" : "Disabled"
}}</span>
<span v-if="row.auto_mail && row.email_template_id" class="text-gray-600">
Template:
@ -584,7 +549,7 @@ const destroyDecision = () => {
</span>
</div>
</template>
<template #cell-actions="{ row }">
<template #actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
@ -607,12 +572,12 @@ const destroyDecision = () => {
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTableNew2>
</DataTableClient>
</div>
<Dialog v-model:open="drawerEdit">
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Uredi odločitev</DialogTitle>
<DialogTitle>Spremeni odločitev</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div>
@ -703,16 +668,9 @@ const destroyDecision = () => {
/>
</div>
<div class="flex items-center gap-2 self-end">
<label
class="flex items-center gap-2 text-sm cursor-pointer select-none"
>
<Switch v-model="ev.active" />
<span
:class="
ev.active ? 'text-green-700 font-medium' : 'text-muted-foreground'
"
>{{ ev.active ? "Aktivno" : "Neaktivno" }}</span
>
<label class="flex items-center gap-2 text-sm">
<Checkbox v-model="ev.active" />
Aktivno
</label>
<Button
variant="ghost"
@ -758,7 +716,7 @@ const destroyDecision = () => {
<template v-else-if="eventKey(ev) === 'archive_contract'">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<InputLabel :for="`as-${idx}`" value="Nastavitev arhiva" />
<InputLabel :for="`as-${idx}`" value="Archive setting" />
<Select v-model="ev.config.archive_setting_id">
<SelectTrigger :id="`as-${idx}`" class="w-full">
<SelectValue placeholder="— Izberi nastavitev —" />
@ -782,14 +740,9 @@ const destroyDecision = () => {
</p>
</div>
<div class="flex items-end">
<label
class="flex items-center gap-2 text-sm mt-6 cursor-pointer select-none"
>
<Switch
:model-value="ev.config.reactivate"
v-model:checked="ev.config.reactivate"
/>
Reaktiviraj namesto arhiviranja
<label class="flex items-center gap-2 text-sm mt-6">
<Checkbox v-model:checked="ev.config.reactivate" />
Reactivate namesto arhiva
</label>
</div>
</div>
@ -799,13 +752,8 @@ const destroyDecision = () => {
Ta dogodek nima dodatnih nastavitev.
</p>
</template>
<template v-else-if="eventKey(ev) === 'add_call_later'">
<p class="text-sm text-muted-foreground">
Datum in ura povratnega klica se vneseta ob ustvarjanju aktivnosti.
</p>
</template>
<template v-else>
<!-- Rezervni urejevalnik za neznane ključe dogodkov -->
<!-- Fallback advanced editor for unknown event keys -->
<InputLabel :for="`cfg-${idx}`" value="Napredna nastavitev (JSON)" />
<textarea
:id="`cfg-${idx}`"
@ -821,104 +769,6 @@ const destroyDecision = () => {
></textarea>
</template>
</div>
<!-- Conditions -->
<div v-if="conditionFields.length" class="mt-3 border-t pt-3">
<div class="flex items-center justify-between mb-2">
<span
class="text-xs font-semibold text-muted-foreground uppercase tracking-wide"
>Pogoji za izvajanje</span
>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 gap-1 text-xs"
@click="addCondition(ev)"
>
<Plus class="w-3 h-3" />
Dodaj pogoj
</Button>
</div>
<p
v-if="!ev.config.conditions?.length"
class="text-xs text-muted-foreground italic"
>
Brez pogojev dogodek se vedno izvede.
</p>
<div
v-for="(cond, cidx) in ev.config.conditions"
:key="cidx"
class="flex items-center gap-2 mt-1"
>
<Select
v-model="cond.field"
@update:model-value="onConditionFieldChange(cond)"
class="w-48"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Polje" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="f in conditionFields"
:key="f.key"
:value="f.key"
class="text-xs"
>
{{ f.label }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model="cond.operator" class="w-36">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Operator" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="op in operatorsForField(cond.field)"
:key="op.key"
:value="op.key"
class="text-xs"
>
{{ op.label }}
</SelectItem>
</SelectContent>
</Select>
<template
v-if="
conditionFields.find((f) => f.key === cond.field)?.type ===
'boolean'
"
>
<Select v-model="cond.value" class="w-24">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Vrednost" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1" class="text-xs">Da</SelectItem>
<SelectItem value="0" class="text-xs">Ne</SelectItem>
</SelectContent>
</Select>
</template>
<template v-else>
<Input
v-model="cond.value"
class="h-8 text-xs w-28"
placeholder="Vrednost"
/>
</template>
<Button
type="button"
variant="ghost"
size="icon"
class="h-7 w-7 text-red-500 hover:text-red-700"
@click="removeCondition(ev, cidx)"
>
<X class="w-3 h-3" />
</Button>
</div>
</div>
</div>
<div>
<Button
@ -936,7 +786,7 @@ const destroyDecision = () => {
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeEditDrawer">Prekliči</Button>
<Button variant="outline" @click="closeEditDrawer">Cancel</Button>
<Button @click="update" :disabled="form.processing || !eventsValidEdit"
>Shrani</Button
>
@ -1045,16 +895,9 @@ const destroyDecision = () => {
/>
</div>
<div class="flex items-center gap-2 self-end">
<label
class="flex items-center gap-2 text-sm cursor-pointer select-none"
>
<Switch v-model="ev.active" />
<span
:class="
ev.active ? 'text-green-700 font-medium' : 'text-muted-foreground'
"
>{{ ev.active ? "Aktivno" : "Neaktivno" }}</span
>
<label class="flex items-center gap-2 text-sm">
<Checkbox v-model:checked="ev.active" />
Aktivno
</label>
<Button
variant="ghost"
@ -1100,7 +943,7 @@ const destroyDecision = () => {
<template v-else-if="eventKey(ev) === 'archive_contract'">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<InputLabel :for="`cas-${idx}`" value="Nastavitev arhiva" />
<InputLabel :for="`cas-${idx}`" value="Archive setting" />
<Select v-model="ev.config.archive_setting_id">
<SelectTrigger :id="`cas-${idx}`" class="w-full">
<SelectValue placeholder="— Izberi nastavitev —" />
@ -1126,14 +969,9 @@ const destroyDecision = () => {
</p>
</div>
<div class="flex items-end">
<label
class="flex items-center gap-2 text-sm mt-6 cursor-pointer select-none"
>
<Switch
:model-value="ev.config.reactivate"
v-model:checked="ev.config.reactivate"
/>
Reaktiviraj namesto arhiviranja
<label class="flex items-center gap-2 text-sm mt-6">
<Checkbox v-model:checked="ev.config.reactivate" />
Reactivate namesto arhiva
</label>
</div>
</div>
@ -1143,11 +981,6 @@ const destroyDecision = () => {
Ta dogodek nima dodatnih nastavitev.
</p>
</template>
<template v-else-if="eventKey(ev) === 'add_call_later'">
<p class="text-sm text-muted-foreground">
Datum in ura povratnega klica se vneseta ob ustvarjanju aktivnosti.
</p>
</template>
<template v-else>
<InputLabel :for="`ccfg-${idx}`" value="Napredna nastavitev (JSON)" />
<textarea
@ -1164,104 +997,6 @@ const destroyDecision = () => {
></textarea>
</template>
</div>
<!-- Conditions -->
<div v-if="conditionFields.length" class="mt-3 border-t pt-3">
<div class="flex items-center justify-between mb-2">
<span
class="text-xs font-semibold text-muted-foreground uppercase tracking-wide"
>Pogoji za izvajanje</span
>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 gap-1 text-xs"
@click="addCondition(ev)"
>
<Plus class="w-3 h-3" />
Dodaj pogoj
</Button>
</div>
<p
v-if="!ev.config.conditions?.length"
class="text-xs text-muted-foreground italic"
>
Brez pogojev dogodek se vedno izvede.
</p>
<div
v-for="(cond, cidx) in ev.config.conditions"
:key="cidx"
class="flex items-center gap-2 mt-1"
>
<Select
v-model="cond.field"
@update:model-value="onConditionFieldChange(cond)"
class="w-48"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Polje" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="f in conditionFields"
:key="f.key"
:value="f.key"
class="text-xs"
>
{{ f.label }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model="cond.operator" class="w-36">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Operator" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="op in operatorsForField(cond.field)"
:key="op.key"
:value="op.key"
class="text-xs"
>
{{ op.label }}
</SelectItem>
</SelectContent>
</Select>
<template
v-if="
conditionFields.find((f) => f.key === cond.field)?.type ===
'boolean'
"
>
<Select v-model="cond.value" class="w-24">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Vrednost" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1" class="text-xs">Da</SelectItem>
<SelectItem value="0" class="text-xs">Ne</SelectItem>
</SelectContent>
</Select>
</template>
<template v-else>
<Input
v-model="cond.value"
class="h-8 text-xs w-28"
placeholder="Vrednost"
/>
</template>
<Button
type="button"
variant="ghost"
size="icon"
class="h-7 w-7 text-red-500 hover:text-red-700"
@click="removeCondition(ev, cidx)"
>
<X class="w-3 h-3" />
</Button>
</div>
</div>
</div>
<div>
<Button
@ -1279,7 +1014,7 @@ const destroyDecision = () => {
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="closeCreateDrawer">Prekliči</Button>
<Button variant="outline" @click="closeCreateDrawer">Cancel</Button>
<Button @click="store" :disabled="createForm.processing || !eventsValidCreate"
>Dodaj</Button
>
@ -1290,15 +1025,15 @@ const destroyDecision = () => {
<AlertDialog v-model:open="showDelete">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Zbriši odločitev</AlertDialogTitle>
<AlertDialogTitle>Delete decision</AlertDialogTitle>
</AlertDialogHeader>
<div class="text-sm text-muted-foreground">
Ali ste prepričani, da želite zbrisati odločitev "{{ toDelete?.name }}"? Tega
dejanja ni mogoče razveljaviti.
Are you sure you want to delete decision "{{ toDelete?.name }}"? This cannot be
undone.
</div>
<AlertDialogFooter>
<Button variant="outline" @click="cancelDelete">Prekliči</Button>
<Button variant="destructive" @click="destroyDecision">Zbriši</Button>
<Button variant="outline" @click="cancelDelete">Cancel</Button>
<Button variant="destructive" @click="destroyDecision">Delete</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@ -1,4 +1,4 @@
export const fmtDateTime = (d) => {
export function fmtDateTime(d) {
if (!d) return "";
try {
const dt = new Date(d);
@ -33,12 +33,8 @@ export function fmtDateDMY(value) {
if (!value) return "-";
const d = new Date(value);
if (isNaN(d)) return "-";
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Ljubljana",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).formatToParts(d);
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
return `${map.day}.${map.month}.${map.year}`;
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
return `${dd}.${mm}.${yyyy}`;
}

View File

@ -1,7 +1,6 @@
<?php
use App\Http\Controllers\AccountBookingController;
use App\Http\Controllers\AccountInstallmentController;
use App\Http\Controllers\AccountPaymentController;
use App\Http\Controllers\ActivityNotificationController;
use App\Http\Controllers\ArchiveSettingController;
@ -9,12 +8,10 @@
use App\Http\Controllers\ClientCaseContoller;
use App\Http\Controllers\ClientController;
use App\Http\Controllers\ContractConfigController;
use App\Http\Controllers\ContractSettingController;
use App\Http\Controllers\FieldJobController;
use App\Http\Controllers\FieldJobSettingController;
use App\Http\Controllers\ImportController;
use App\Http\Controllers\ImportTemplateController;
use App\Http\Controllers\InstallmentSettingController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\PaymentSettingController;
use App\Http\Controllers\PersonController;
@ -86,7 +83,6 @@
Route::post('users', [\App\Http\Controllers\Admin\UserRoleController::class, 'store'])->name('users.store');
Route::put('users/{user}', [\App\Http\Controllers\Admin\UserRoleController::class, 'update'])->name('users.update');
Route::patch('users/{user}/toggle-active', [\App\Http\Controllers\Admin\UserRoleController::class, 'toggleActive'])->name('users.toggle-active');
Route::patch('users/{user}/settings', [\App\Http\Controllers\Admin\UserRoleController::class, 'updateSettings'])->name('users.settings');
// Permissions management
Route::get('permissions', [\App\Http\Controllers\Admin\PermissionController::class, 'index'])->name('permissions.index');
@ -161,19 +157,18 @@
Route::get('sms-logs', [\App\Http\Controllers\Admin\SmsLogController::class, 'index'])->name('sms-logs.index');
Route::get('sms-logs/{smsLog}', [\App\Http\Controllers\Admin\SmsLogController::class, 'show'])->name('sms-logs.show');
});
// Packages (batch jobs)
Route::get('packages', [\App\Http\Controllers\Admin\PackageController::class, 'index'])->name('packages.index');
Route::get('packages/create', [\App\Http\Controllers\Admin\PackageController::class, 'create'])->name('packages.create');
Route::get('packages/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'show'])->name('packages.show');
Route::post('packages', [\App\Http\Controllers\Admin\PackageController::class, 'store'])->name('packages.store');
Route::post('packages/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('packages.dispatch');
Route::post('packages/{package}/cancel', [\App\Http\Controllers\Admin\PackageController::class, 'cancel'])->name('packages.cancel');
Route::delete('packages/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'destroy'])->name('packages.destroy');
// Packages - contract-based helpers
Route::get('packages-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('packages.contracts');
Route::post('packages-from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('packages.store-from-contracts');
// Packages (SMS batch sender) — accessible to users with manage-settings permission
Route::middleware(['permission:manage-settings'])->prefix('packages')->name('packages.')->group(function () {
Route::get('/', [\App\Http\Controllers\Admin\PackageController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Admin\PackageController::class, 'create'])->name('create');
Route::get('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'show'])->name('show');
Route::post('/', [\App\Http\Controllers\Admin\PackageController::class, 'store'])->name('store');
Route::post('/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('dispatch');
Route::post('/{package}/cancel', [\App\Http\Controllers\Admin\PackageController::class, 'cancel'])->name('cancel');
Route::delete('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'destroy'])->name('destroy');
Route::get('/contracts/list', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('contracts');
Route::post('/from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('store-from-contracts');
});
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
@ -506,24 +501,12 @@
Route::get('bookings', [AccountBookingController::class, 'index'])->name('bookings.index');
Route::post('bookings', [AccountBookingController::class, 'store'])->name('bookings.store');
Route::delete('bookings/{booking}', [AccountBookingController::class, 'destroy'])->name('bookings.destroy');
Route::get('installments/list', [AccountInstallmentController::class, 'list'])->name('installments.list');
Route::post('installments', [AccountInstallmentController::class, 'store'])->name('installments.store');
Route::delete('installments/{installment}', [AccountInstallmentController::class, 'destroy'])->name('installments.destroy');
});
// settings - payment settings
Route::get('settings/payment', [PaymentSettingController::class, 'edit'])->name('settings.payment.edit');
Route::put('settings/payment', [PaymentSettingController::class, 'update'])->name('settings.payment.update');
// settings - installment settings
Route::get('settings/installment', [InstallmentSettingController::class, 'edit'])->name('settings.installment.edit');
Route::put('settings/installment', [InstallmentSettingController::class, 'update'])->name('settings.installment.update');
// settings - contract settings
Route::get('settings/contract', [ContractSettingController::class, 'edit'])->name('settings.contract.edit');
Route::put('settings/contract', [ContractSettingController::class, 'update'])->name('settings.contract.update');
Route::get('types/address', function (Request $request) {
$types = App\Models\Person\AddressType::all();
@ -543,8 +526,4 @@
});
Route::get('reports/{slug}/export', [\App\Http\Controllers\ReportController::class, 'export'])->middleware('permission:reports-export')->name('reports.export');
// Call laters
Route::get('call-laters', [\App\Http\Controllers\CallLaterController::class, 'index'])->name('callLaters.index');
Route::patch('call-laters/{callLater}/complete', [\App\Http\Controllers\CallLaterController::class, 'complete'])->name('callLaters.complete');
});

View File

@ -72,7 +72,7 @@
$contract3->segments()->attach($segment->id, ['active' => true]);
// Test without date filters - should return all contracts
$response = $this->getJson(route('packages.contracts', [
$response = $this->getJson(route('admin.packages.contracts', [
'segment_id' => $segment->id,
]));
@ -81,7 +81,7 @@
expect($data)->toHaveCount(3);
// Test with start_date_from filter
$response = $this->getJson(route('packages.contracts', [
$response = $this->getJson(route('admin.packages.contracts', [
'segment_id' => $segment->id,
'start_date_from' => '2024-02-01',
]));
@ -92,7 +92,7 @@
expect(collect($data)->pluck('reference'))->toContain('CONTRACT-2024-002', 'CONTRACT-2024-003');
// Test with start_date_to filter
$response = $this->getJson(route('packages.contracts', [
$response = $this->getJson(route('admin.packages.contracts', [
'segment_id' => $segment->id,
'start_date_to' => '2024-03-31',
]));
@ -103,7 +103,7 @@
expect(collect($data)->pluck('reference'))->toContain('CONTRACT-2024-001', 'CONTRACT-2024-002');
// Test with both date filters
$response = $this->getJson(route('packages.contracts', [
$response = $this->getJson(route('admin.packages.contracts', [
'segment_id' => $segment->id,
'start_date_from' => '2024-02-01',
'start_date_to' => '2024-04-30',
@ -133,7 +133,7 @@
$segment = Segment::factory()->create(['active' => true]);
// Test invalid start_date_from
$response = $this->getJson(route('packages.contracts', [
$response = $this->getJson(route('admin.packages.contracts', [
'segment_id' => $segment->id,
'start_date_from' => 'invalid-date',
]));
@ -142,7 +142,7 @@
$response->assertJsonValidationErrors('start_date_from');
// Test invalid start_date_to
$response = $this->getJson(route('packages.contracts', [
$response = $this->getJson(route('admin.packages.contracts', [
'segment_id' => $segment->id,
'start_date_to' => 'invalid-date',
]));

View File

@ -1,46 +0,0 @@
<?php
// Override the global uses() so these pure-logic tests skip RefreshDatabase
uses(\PHPUnit\Framework\TestCase::class);
/**
* Unit-level tests for the decision_ids filter logic used in AutoMailDispatcher.
* These tests execute the filter predicate in isolation without database interaction.
*/
/**
* Simulates the filter closure from AutoMailDispatcher::maybeQueue().
*
* @param array<string,mixed> $preferences
*/
function emailPassesDecisionFilter(array $preferences, int $decisionId): bool
{
$decisionIds = $preferences['decision_ids'] ?? [];
if (empty($decisionIds)) {
return true;
}
return in_array($decisionId, array_map('intval', $decisionIds), true);
}
it('email with no decision_ids restriction passes the filter for any decision', function () {
expect(emailPassesDecisionFilter([], 5))->toBeTrue();
});
it('email with a matching decision_id in preferences passes the filter', function () {
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 7))->toBeTrue();
});
it('email with a non-matching decision_id in preferences is filtered out', function () {
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 99))->toBeFalse();
});
it('email with empty preferences is treated as no restriction', function () {
expect(emailPassesDecisionFilter([], 42))->toBeTrue();
});
it('string decision ids in preferences are cast to int for comparison', function () {
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 7))->toBeTrue();
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 99))->toBeFalse();
});

View File

@ -3,4 +3,3 @@
use Tests\TestCase;
uses(TestCase::class)->in('Feature', 'Unit');
uses(\PHPUnit\Framework\TestCase::class)->in('Pure');

View File

@ -1,43 +0,0 @@
<?php
/**
* Unit-level tests for the decision_ids filter logic used in AutoMailDispatcher.
* These tests execute the filter predicate in isolation without database interaction.
*/
/**
* Simulates the filter closure from AutoMailDispatcher::maybeQueue().
*
* @param array<string,mixed> $preferences
*/
function emailPassesDecisionFilter(array $preferences, int $decisionId): bool
{
$decisionIds = $preferences['decision_ids'] ?? [];
if (empty($decisionIds)) {
return true;
}
return in_array($decisionId, array_map('intval', $decisionIds), true);
}
it('email with no decision_ids restriction passes the filter for any decision', function () {
expect(emailPassesDecisionFilter([], 5))->toBeTrue();
});
it('email with a matching decision_id in preferences passes the filter', function () {
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 7))->toBeTrue();
});
it('email with a non-matching decision_id in preferences is filtered out', function () {
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 99))->toBeFalse();
});
it('email with empty preferences is treated as no restriction', function () {
expect(emailPassesDecisionFilter([], 42))->toBeTrue();
});
it('string decision ids in preferences are cast to int for comparison', function () {
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 7))->toBeTrue();
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 99))->toBeFalse();
});