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',
@ -355,14 +326,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
// Determine which contracts to process
$contractIds = [];
if ($createForAll && ! empty($contractUuids)) {
if ($createForAll && !empty($contractUuids)) {
// Get all contract IDs from the provided UUIDs
$contracts = Contract::withTrashed()
->whereIn('uuid', $contractUuids)
->where('client_case_id', $clientCase->id)
->get();
$contractIds = $contracts->pluck('id')->toArray();
} elseif (! empty($contractUuids) && isset($contractUuids[0])) {
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
// Single contract mode
$contract = Contract::withTrashed()
->where('uuid', $contractUuids[0])
@ -371,7 +342,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
if ($contract) {
$contractIds = [$contract->id];
}
} elseif (! empty($attributes['contract_uuid'])) {
} elseif (!empty($attributes['contract_uuid'])) {
// Legacy single contract_uuid support
$contract = Contract::withTrashed()
->where('uuid', $attributes['contract_uuid'])
@ -389,7 +360,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
$createdActivities = [];
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
// Disable auto mail if creating activities for multiple contracts
if ($sendFlag && count($contractIds) > 1) {
$sendFlag = false;
@ -400,7 +371,6 @@ public function storeActivity(ClientCase $clientCase, Request $request)
// Create activity
$row = $clientCase->activities()->create([
'due_date' => $attributes['due_date'] ?? null,
'call_back_at' => $attributes['call_back_at'] ?? null,
'amount' => $attributes['amount'] ?? null,
'note' => $attributes['note'] ?? null,
'action_id' => $attributes['action_id'],
@ -447,29 +417,29 @@ public function storeActivity(ClientCase $clientCase, Request $request)
->whereIn('id', $attachmentIds)
->pluck('id');
$validAttachmentIds = Document::query()
->where('documentable_type', Contract::class)
->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds)
->pluck('id');
}
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
'attachment_ids' => $validAttachmentIds->all(),
]);
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
// If template requires contract and user attempted to send, surface a validation message
logger()->warning('Email not queued: required contract is missing for the selected template.');
}
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
}
} catch (\Throwable $e) {
// Do not fail activity creation due to mailing issues
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
->where('documentable_type', Contract::class)
->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds)
->pluck('id');
}
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
'attachment_ids' => $validAttachmentIds->all(),
]);
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
// If template requires contract and user attempted to send, surface a validation message
logger()->warning('Email not queued: required contract is missing for the selected template.');
}
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
}
} catch (\Throwable $e) {
// Do not fail activity creation due to mailing issues
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
}
}
$activityCount = count($createdActivities);
$successMessage = $activityCount > 1
$successMessage = $activityCount > 1
? "Successfully created {$activityCount} activities!"
: 'Successfully created activity!';
@ -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',
]);
@ -1149,14 +1115,13 @@ public function archiveBatch(Request $request)
foreach ($validated['contracts'] as $contractUuid) {
try {
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
// Skip if contract is already archived (active = 0)
if (! $contract->active) {
if (!$contract->active) {
$skippedCount++;
continue;
}
$clientCase = $contract->clientCase;
$context = [
@ -1243,8 +1208,8 @@ public function archiveBatch(Request $request)
if ($skippedCount > 0) {
$message .= ", skipped $skippedCount already archived";
}
$message .= ', '.count($errors).' failed';
$message .= ", " . count($errors) . " failed";
return back()->with('flash', [
'error' => $message,
'details' => $errors,
@ -1254,7 +1219,7 @@ public function archiveBatch(Request $request)
$message = $reactivate
? "Successfully reactivated $successCount contracts"
: "Successfully archived $successCount contracts";
if ($skippedCount > 0) {
$message .= " ($skippedCount already archived)";
}
@ -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

@ -27,7 +27,7 @@ public function index(Client $client, Request $request)
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id');
})
// ->where('clients.active', 1)
//->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) {
@ -40,8 +40,12 @@ public function index(Client $client, Request $request)
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('clients.id')
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count')
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
->addSelect([
// Number of client cases for this client that have at least one active contract
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
// Sum of account balances for active contracts
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->with('person')
->orderByDesc('clients.created_at');
@ -67,7 +71,6 @@ public function show(Client $client, Request $request)
return Inertia::render('Client/Show', [
'client' => $data,
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
'client_cases' => $data->clientCases()
->select('client_cases.*')
->when($request->input('search'), function ($que, $search) {
@ -85,8 +88,10 @@ public function show(Client $client, Request $request)
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('client_cases.id')
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
->addSelect([
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->with(['person', 'client.person'])
->where('client_cases.active', 1)
->orderByDesc('client_cases.created_at')
@ -157,7 +162,6 @@ public function contracts(Client $client, Request $request)
return Inertia::render('Client/Contracts', [
'client' => $data,
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
'contracts' => $contractsQuery
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
->withQueryString(),
@ -171,7 +175,7 @@ public function exportContracts(ExportClientContractsRequest $request, Client $c
{
$data = $request->validated();
$columns = array_values(array_unique($data['columns']));
$from = $data['from'] ?? null;
$to = $data['to'] ?? null;
$search = $data['search'] ?? null;
@ -232,7 +236,7 @@ private function buildExportFilename(Client $client): string
{
$datePrefix = now()->format('dmy');
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
}

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;
@ -46,9 +47,9 @@ public function __invoke(SmsService $sms): Response
return Account::whereHas('contract', function ($q) {
$q->whereNull('deleted_at');
})
->whereNotNull('promise_date')
->whereDate('promise_date', '>=', $today)
->count();
->whereNotNull('promise_date')
->whereDate('promise_date', '>=', $today)
->count();
});
// Activities (limit 10) - cached
@ -79,14 +80,14 @@ public function __invoke(SmsService $sms): Response
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
->selectRaw("DATE(COALESCE(assigned_at, created_at) AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
// Completed field jobs last 7 days
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
->whereBetween('completed_at', [$start, $end])
->selectRaw("DATE(completed_at AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
@ -100,13 +101,13 @@ public function __invoke(SmsService $sms): Response
// Field jobs assigned today - cached
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
return FieldJob::query()
->whereRaw('DATE(COALESCE(assigned_at, created_at)) = ?', [$today->toDateString()])
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
->with(['contract' => function ($q) {
$q->select('id', 'uuid', 'reference', 'client_case_id')
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
}])
->orderByRaw('COALESCE(assigned_at, created_at) DESC')
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
->limit(15)
->get()
->map(function ($fj) {
@ -119,26 +120,20 @@ public function __invoke(SmsService $sms): Response
}
}
if (! $contract) {
return null;
}
return [
'id' => $fj->id,
'priority' => $fj->priority,
'assigned_at' => $fj->assigned_at?->toIso8601String(),
'created_at' => $fj->created_at?->toIso8601String(),
'contract' => [
'contract' => $contract ? [
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'client_case_uuid' => optional($contract->clientCase)->uuid,
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
'segment_id' => $segmentId,
],
] : null,
];
})
->filter()
->values();
});
});
// System health for timestamp

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)
@ -71,7 +72,7 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$address->update($attributes);
return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
}
public function deleteAddress(Person $person, int $address_id, Request $request)
@ -79,6 +80,7 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
$address = $person->addresses()->findOrFail($address_id);
$address->delete(); // soft delete
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
}
@ -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,7 +238,8 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete();
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
}
}

View File

@ -10,40 +10,42 @@
class PhoneViewController extends Controller
{
public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Request $request): \Inertia\Response
public function index(Request $request)
{
$userId = $request->user()->id;
$search = $request->input('search');
$clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$eagerLoad = [
'contract' => function ($q) {
$q->with([
'type:id,name',
'account',
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
];
$baseQuery = FieldJob::query()
$query = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->with($eagerLoad);
->with([
'contract' => function ($q) {
$q->with([
'type:id,name',
'account',
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
])
->orderByDesc('assigned_at');
// Apply client filter
if ($clientFilter) {
$baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
$q->where('uuid', $clientFilter);
});
}
// Apply search filter
if ($search) {
$baseQuery->where(function ($q) use ($search) {
$query->where(function ($q) use ($search) {
$q->whereHas('contract', function ($cq) use ($search) {
$cq->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($pq) use ($search) {
@ -56,14 +58,9 @@ public function index(Request $request): \Inertia\Response
});
}
$pendingQuery = (clone $baseQuery)
->where(fn ($q) => $q->where('added_activity', false)->orWhereNull('added_activity'))
->orderByDesc('assigned_at');
$processedQuery = (clone $baseQuery)
->where('added_activity', true)
->orderByDesc('assigned_at');
$jobs = $query->paginate($perPage)->withQueryString();
// Get unique clients for filter dropdown
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
$q->where('assigned_user_id', $userId)
@ -80,8 +77,7 @@ public function index(Request $request): \Inertia\Response
->values();
return Inertia::render('Phone/Index', [
'pendingJobs' => Inertia::scroll(fn () => $pendingQuery->paginate(15, pageName: 'pending')),
'processedJobs' => Inertia::scroll(fn () => $processedQuery->paginate(15, pageName: 'processed')),
'jobs' => $jobs,
'clients' => $clients,
'view_mode' => 'assigned',
'filters' => [
@ -91,11 +87,13 @@ public function index(Request $request): \Inertia\Response
]);
}
public function completedToday(Request $request): \Inertia\Response
public function completedToday(Request $request)
{
$userId = $request->user()->id;
$search = $request->input('search');
$clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$start = now()->startOfDay();
$end = now()->endOfDay();
@ -140,6 +138,9 @@ public function completedToday(Request $request): \Inertia\Response
});
}
$jobs = $query->paginate($perPage)->withQueryString();
// Get unique clients for filter dropdown
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
$q->where('assigned_user_id', $userId)
@ -156,7 +157,7 @@ public function completedToday(Request $request): \Inertia\Response
->values();
return Inertia::render('Phone/Index', [
'completedJobs' => Inertia::scroll(fn () => $query->paginate(15, pageName: 'completed')),
'jobs' => $jobs,
'clients' => $clients,
'view_mode' => 'completed-today',
'filters' => [

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

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,57 +452,11 @@ const open = computed({
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[65vh] pr-1">
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField v-slot="{ value, handleChange }" name="profile_id">
<FormItem>
<FormLabel>Profil</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="p in pageSmsProfiles" :key="p.id" :value="p.id">
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="sender_id">
<FormItem>
<FormLabel>Pošiljatelj</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="s in sendersForSelectedProfile"
:key="s.id"
:value="s.id"
>
{{ s.name || s.phone || "Sender #" + s.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
<form @submit.prevent="onSubmit" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField v-slot="{ value, handleChange }" name="profile_id">
<FormItem>
<FormLabel>Pogodba</FormLabel>
<FormLabel>Profil</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
@ -512,22 +465,18 @@ const open = computed({
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="c in contractsForCase" :key="c.uuid" :value="c.uuid">
{{ c.reference || c.uuid }}
<SelectItem v-for="p in pageSmsProfiles" :key="p.id" :value="p.id">
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in
{account.*} mest.
</p>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="template_id">
<FormField v-slot="{ value, handleChange }" name="sender_id">
<FormItem>
<FormLabel>Predloga</FormLabel>
<FormLabel>Pošiljatelj</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
@ -536,77 +485,125 @@ const open = computed({
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
{{ t.name || "Predloga #" + t.id }}
<SelectItem
v-for="s in sendersForSelectedProfile"
:key="s.id"
:value="s.id"
>
{{ s.name || s.phone || "Sender #" + s.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>Vsebina sporočila</FormLabel>
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
<FormItem>
<FormLabel>Pogodba</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<Textarea
rows="4"
placeholder="Vpišite SMS vsebino..."
v-bind="componentField"
/>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Live counters -->
<div class="text-xs text-gray-600 flex flex-col gap-1">
<div>
<span class="font-medium">Znakov:</span>
<span class="font-mono">{{ charCount }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Kodiranje:</span>
<span>{{ smsEncoding }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Deli SMS:</span>
<span class="font-mono">{{ segments }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Krediti:</span>
<span class="font-mono">{{ creditsNeeded }}</span>
</div>
<div>
<span class="font-medium">Omejitev:</span>
<span class="font-mono">{{ maxAllowed }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Preostanek:</span>
<span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">
{{ remaining }}
</span>
</div>
<p class="text-[11px] text-gray-500 leading-snug">
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake,
ki ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS2). V tem
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in )
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM7) oziroma
320 (UCS2) znakov.
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="c in contractsForCase" :key="c.uuid" :value="c.uuid">
{{ c.reference || c.uuid }}
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in
{account.*} mest.
</p>
</div>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="delivery_report">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormField v-slot="{ value, handleChange }" name="template_id">
<FormItem>
<FormLabel>Predloga</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>Zahtevaj poročilo o dostavi</FormLabel>
</div>
</FormItem>
</FormField>
</form>
</ScrollArea>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
{{ t.name || "Predloga #" + t.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>Vsebina sporočila</FormLabel>
<FormControl>
<Textarea
rows="4"
placeholder="Vpišite SMS vsebino..."
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Live counters -->
<div class="text-xs text-gray-600 flex flex-col gap-1">
<div>
<span class="font-medium">Znakov:</span>
<span class="font-mono">{{ charCount }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Kodiranje:</span>
<span>{{ smsEncoding }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Deli SMS:</span>
<span class="font-mono">{{ segments }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Krediti:</span>
<span class="font-mono">{{ creditsNeeded }}</span>
</div>
<div>
<span class="font-medium">Omejitev:</span>
<span class="font-mono">{{ maxAllowed }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Preostanek:</span>
<span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">
{{ remaining }}
</span>
</div>
<p class="text-[11px] text-gray-500 leading-snug">
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake, ki
ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS2). V tem
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in )
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM7) oziroma
320 (UCS2) znakov.
</p>
</div>
<FormField v-slot="{ value, handleChange }" name="delivery_report">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>Zahtevaj poročilo o dostavi</FormLabel>
</div>
</FormItem>
</FormField>
</form>
<DialogFooter>
<Button variant="outline" @click="closeSmsDialog" :disabled="processing">

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,27 +73,31 @@ 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>
<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" />
</ItemMedia>
<ItemContent>
<ItemTitle>
<span>{{ f.contract.person_full_name }}</span>
</ItemTitle>
<ItemDescription class="flex gap-1">
<Badge>{{ f.contract.reference }}</Badge>
<Badge variant="outline">{{ formatJobTime(f.created_at) }}</Badge>
</ItemDescription>
</ItemContent>
<ItemActions>
<ChevronRightIcon class="size-4" />
</ItemActions>
</a>
</Item>
</template>
<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" />
</ItemMedia>
<ItemContent>
<ItemTitle>
<span>{{ f.contract.person_full_name }}</span>
</ItemTitle>
<ItemDescription class="flex gap-1">
<Badge>{{ f.contract.reference }}</Badge>
<Badge variant="outline">{{ formatJobTime(f.created_at) }}</Badge>
</ItemDescription>
</ItemContent>
<ItemActions>
<ChevronRightIcon class="size-4" />
</ItemActions>
</a>
</Item>
</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" }}
</CardTitle>
<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>
<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-lg font-semibold text-gray-900 dark:text-gray-100"
>
{{ formatAmount(c.account.balance_amount) }}
</span>
</div>
</div>
</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>
<!-- 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
<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
v-if="c.last_object.type"
class="ml-2 text-xs font-normal text-gray-500"
>
({{ c.last_object.type }})
</span>
</div>
<div
v-if="c.last_object.description"
class="text-sm text-gray-600 dark:text-gray-400"
>
{{ c.last_object.description }}
</div>
</div>
<span
class="text-2xl font-bold text-red-600 dark:text-red-400 tabular-nums"
>
{{ 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"
>
<span
class="text-xs text-gray-500 dark:text-gray-400 shrink-0"
>{{ val?.title || key }}</span
>
<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>
</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"
>
<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>
</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") }}

File diff suppressed because it is too large Load Diff

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