Compare commits
No commits in common. "master" and "Development" have entirely different histories.
master
...
Developmen
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ public function index(Request $request): Response
|
|||
{
|
||||
Gate::authorize('manage-settings');
|
||||
|
||||
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active', 'login_redirect']);
|
||||
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active']);
|
||||
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
|
||||
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
|
||||
|
||||
|
|
@ -73,17 +73,4 @@ public function toggleActive(User $user): RedirectResponse
|
|||
|
||||
return back()->with('success', "Uporabnik {$status}");
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
Gate::authorize('manage-settings');
|
||||
|
||||
$validated = $request->validate([
|
||||
'login_redirect' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$user->update($validated);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CallLater;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class CallLaterController extends Controller
|
||||
{
|
||||
public function index(Request $request): \Inertia\Response
|
||||
{
|
||||
$query = CallLater::query()
|
||||
->with([
|
||||
'clientCase.person',
|
||||
'contract',
|
||||
'user',
|
||||
'activity',
|
||||
])
|
||||
->whereNull('completed_at')
|
||||
->orderBy('call_back_at', 'asc');
|
||||
|
||||
if ($request->filled('date_from')) {
|
||||
$query->whereDate('call_back_at', '>=', $request->date_from);
|
||||
}
|
||||
if ($request->filled('date_to')) {
|
||||
$query->whereDate('call_back_at', '<=', $request->date_to);
|
||||
}
|
||||
if ($request->filled('search')) {
|
||||
$term = '%'.$request->search.'%';
|
||||
$query->whereHas('clientCase.person', function ($q) use ($term) {
|
||||
$q->where('first_name', 'ilike', $term)
|
||||
->orWhere('last_name', 'ilike', $term)
|
||||
->orWhere('full_name', 'ilike', $term)
|
||||
->orWhereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", [$term]);
|
||||
});
|
||||
}
|
||||
|
||||
$callLaters = $query->paginate(50)->withQueryString();
|
||||
|
||||
return Inertia::render('CallLaters/Index', [
|
||||
'callLaters' => $callLaters,
|
||||
'filters' => $request->only(['date_from', 'date_to', 'search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete(CallLater $callLater): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$callLater->update(['completed_at' => now()]);
|
||||
|
||||
return back()->with('success', 'Klic označen kot opravljen.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'])
|
||||
|
|
@ -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,25 +417,25 @@ 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);
|
||||
|
|
@ -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',
|
||||
]);
|
||||
|
|
@ -1151,9 +1117,8 @@ public function archiveBatch(Request $request)
|
|||
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
||||
|
||||
// Skip if contract is already archived (active = 0)
|
||||
if (! $contract->active) {
|
||||
if (!$contract->active) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -1243,7 +1208,7 @@ public function archiveBatch(Request $request)
|
|||
if ($skippedCount > 0) {
|
||||
$message .= ", skipped $skippedCount already archived";
|
||||
}
|
||||
$message .= ', '.count($errors).' failed';
|
||||
$message .= ", " . count($errors) . " failed";
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => $message,
|
||||
|
|
@ -1380,10 +1345,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
|||
if (! empty($validated['sender_id'])) {
|
||||
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
|
||||
if (! $sender) {
|
||||
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
||||
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
||||
}
|
||||
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
|
||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||
}
|
||||
}
|
||||
if (! $profile) {
|
||||
|
|
@ -1426,7 +1391,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
|||
}
|
||||
|
||||
// Create an activity before sending
|
||||
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
||||
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
||||
$activityData = [
|
||||
'note' => $activityNote,
|
||||
'user_id' => optional($request->user())->id,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
|
|
@ -46,9 +47,9 @@ public function __invoke(SmsService $sms): Response
|
|||
return Account::whereHas('contract', function ($q) {
|
||||
$q->whereNull('deleted_at');
|
||||
})
|
||||
->whereNotNull('promise_date')
|
||||
->whereDate('promise_date', '>=', $today)
|
||||
->count();
|
||||
->whereNotNull('promise_date')
|
||||
->whereDate('promise_date', '>=', $today)
|
||||
->count();
|
||||
});
|
||||
|
||||
// Activities (limit 10) - cached
|
||||
|
|
@ -79,14 +80,14 @@ public function __invoke(SmsService $sms): Response
|
|||
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
||||
|
||||
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||
->selectRaw("DATE(COALESCE(assigned_at, created_at) AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
|
||||
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
// Completed field jobs last 7 days
|
||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
->selectRaw("DATE(completed_at AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
|
||||
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
|
|
@ -100,13 +101,13 @@ public function __invoke(SmsService $sms): Response
|
|||
// Field jobs assigned today - cached
|
||||
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||
return FieldJob::query()
|
||||
->whereRaw('DATE(COALESCE(assigned_at, created_at)) = ?', [$today->toDateString()])
|
||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
||||
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||
->with(['contract' => function ($q) {
|
||||
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
||||
}])
|
||||
->orderByRaw('COALESCE(assigned_at, created_at) DESC')
|
||||
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(function ($fj) {
|
||||
|
|
@ -119,26 +120,20 @@ public function __invoke(SmsService $sms): Response
|
|||
}
|
||||
}
|
||||
|
||||
if (! $contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $fj->id,
|
||||
'priority' => $fj->priority,
|
||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||
'created_at' => $fj->created_at?->toIso8601String(),
|
||||
'contract' => [
|
||||
'contract' => $contract ? [
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||
'segment_id' => $segmentId,
|
||||
],
|
||||
] : null,
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
});
|
||||
});
|
||||
|
||||
// System health for timestamp
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ public function index(Request $request)
|
|||
'current_page' => $paginator->currentPage(),
|
||||
'from' => $paginator->firstItem(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'links' => $paginator->linkCollection()->toArray(),
|
||||
'path' => $paginator->path(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'to' => $paginator->lastItem(),
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateInstallmentSettingRequest;
|
||||
use App\Models\Action;
|
||||
use App\Models\Decision;
|
||||
use App\Models\InstallmentSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class InstallmentSettingController extends Controller
|
||||
{
|
||||
public function edit(): Response
|
||||
{
|
||||
$setting = InstallmentSetting::query()->first();
|
||||
if (! $setting) {
|
||||
$setting = InstallmentSetting::query()->create([
|
||||
'default_currency' => 'EUR',
|
||||
'create_activity_on_installment' => false,
|
||||
'default_decision_id' => null,
|
||||
'default_action_id' => null,
|
||||
'activity_note_template' => 'Dodan obrok: {amount} {currency}',
|
||||
]);
|
||||
}
|
||||
|
||||
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||
$actions = Action::query()
|
||||
->with(['decisions:id'])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (Action $a) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'decision_ids' => $a->decisions->pluck('id')->values(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Settings/Installments/Index', [
|
||||
'setting' => [
|
||||
'id' => $setting->id,
|
||||
'default_currency' => $setting->default_currency,
|
||||
'create_activity_on_installment' => (bool) $setting->create_activity_on_installment,
|
||||
'default_decision_id' => $setting->default_decision_id,
|
||||
'default_action_id' => $setting->default_action_id,
|
||||
'activity_note_template' => $setting->activity_note_template,
|
||||
],
|
||||
'decisions' => $decisions,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateInstallmentSettingRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$setting = InstallmentSetting::query()->firstOrFail();
|
||||
|
||||
$data['create_activity_on_installment'] = (bool) ($data['create_activity_on_installment'] ?? false);
|
||||
|
||||
$setting->update($data);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene.');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BankAccount;
|
||||
use App\Models\Person\Person;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
|
@ -21,14 +22,14 @@ public function update(Person $person, Request $request)
|
|||
'tax_number' => 'nullable|integer',
|
||||
'social_security_number' => 'nullable|integer',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'employer' => 'nullable|string|max:255',
|
||||
'birthday' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$person->update($attributes);
|
||||
|
||||
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function createAddress(Person $person, Request $request)
|
||||
|
|
@ -79,6 +80,7 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
|||
$address = $person->addresses()->findOrFail($address_id);
|
||||
$address->delete(); // soft delete
|
||||
|
||||
|
||||
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
||||
}
|
||||
|
||||
|
|
@ -140,14 +142,8 @@ public function createEmail(Person $person, Request $request)
|
|||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||
unset($attributes['decision_ids']);
|
||||
$attributes['preferences'] = array_merge($attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||
|
||||
// Dedup: avoid duplicate email per person by value
|
||||
$email = $person->emails()->firstOrCreate([
|
||||
'value' => $attributes['value'],
|
||||
|
|
@ -168,16 +164,10 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
|||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$email = $person->emails()->findOrFail($email_id);
|
||||
|
||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||
unset($attributes['decision_ids']);
|
||||
$attributes['preferences'] = array_merge($email->preferences ?? [], $attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||
|
||||
$email->update($attributes);
|
||||
|
||||
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
||||
|
|
@ -214,8 +204,10 @@ public function createTrr(Person $person, Request $request)
|
|||
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||
$trr = $person->bankAccounts()->create($attributes);
|
||||
|
||||
|
||||
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function updateTrr(Person $person, int $trr_id, Request $request)
|
||||
|
|
@ -246,6 +238,7 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
|||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->delete();
|
||||
|
||||
|
||||
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
use App\Models\Decision;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\Segment;
|
||||
use App\Services\DecisionEvents\ConditionEvaluator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
|
|
@ -23,8 +22,6 @@ public function index(Request $request)
|
|||
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
|
||||
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
|
||||
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
|
||||
'condition_fields' => ConditionEvaluator::availableFields(),
|
||||
'condition_operators' => ConditionEvaluator::availableOperators(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +83,6 @@ public function updateAction(int $id, Request $request)
|
|||
|
||||
public function storeDecision(Request $request)
|
||||
{
|
||||
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
||||
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
||||
|
||||
$attributes = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'color_tag' => 'nullable|string|max:25',
|
||||
|
|
@ -102,14 +96,6 @@ public function storeDecision(Request $request)
|
|||
'events.*.active' => 'sometimes|boolean',
|
||||
'events.*.run_order' => 'nullable|integer',
|
||||
'events.*.config' => 'nullable|array',
|
||||
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
|
||||
'events.*.config.deactivate_previous' => 'sometimes|boolean',
|
||||
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
|
||||
'events.*.config.reactivate' => 'sometimes|boolean',
|
||||
'events.*.config.conditions' => 'nullable|array',
|
||||
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
|
||||
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
|
||||
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
|
||||
]);
|
||||
|
||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||
|
|
@ -126,12 +112,12 @@ public function storeDecision(Request $request)
|
|||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||
if ($key === 'add_segment') {
|
||||
$seg = $ev['config']['segment_id'] ?? null;
|
||||
if (empty($seg)) {
|
||||
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
||||
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||
}
|
||||
} elseif ($key === 'archive_contract') {
|
||||
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||
if (empty($as)) {
|
||||
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
|
||||
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
||||
}
|
||||
}
|
||||
|
|
@ -188,9 +174,6 @@ public function updateDecision(int $id, Request $request)
|
|||
{
|
||||
$row = Decision::findOrFail($id);
|
||||
|
||||
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
||||
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
||||
|
||||
$attributes = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'color_tag' => 'nullable|string|max:25',
|
||||
|
|
@ -204,14 +187,6 @@ public function updateDecision(int $id, Request $request)
|
|||
'events.*.active' => 'sometimes|boolean',
|
||||
'events.*.run_order' => 'nullable|integer',
|
||||
'events.*.config' => 'nullable|array',
|
||||
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
|
||||
'events.*.config.deactivate_previous' => 'sometimes|boolean',
|
||||
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
|
||||
'events.*.config.reactivate' => 'sometimes|boolean',
|
||||
'events.*.config.conditions' => 'nullable|array',
|
||||
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
|
||||
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
|
||||
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
|
||||
]);
|
||||
|
||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||
|
|
@ -228,12 +203,12 @@ public function updateDecision(int $id, Request $request)
|
|||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||
if ($key === 'add_segment') {
|
||||
$seg = $ev['config']['segment_id'] ?? null;
|
||||
if (empty($seg)) {
|
||||
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
||||
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||
}
|
||||
} elseif ($key === 'archive_contract') {
|
||||
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||
if (empty($as)) {
|
||||
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
|
||||
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,15 +59,6 @@ public function share(Request $request): array
|
|||
'info' => fn () => $request->session()->get('info'),
|
||||
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
||||
],
|
||||
'callLaterCount' => function () use ($request) {
|
||||
if (! $request->user()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return \App\Models\CallLater::query()
|
||||
->whereNull('completed_at')
|
||||
->count();
|
||||
},
|
||||
'notifications' => function () use ($request) {
|
||||
try {
|
||||
$user = $request->user();
|
||||
|
|
|
|||
|
|
@ -1,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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
@ -31,7 +31,6 @@ class User extends Authenticatable
|
|||
'email',
|
||||
'password',
|
||||
'active',
|
||||
'login_redirect',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DecisionEvents;
|
||||
|
||||
class ConditionEvaluator
|
||||
{
|
||||
/**
|
||||
* Returns true when ALL conditions pass (AND logic).
|
||||
*
|
||||
* Each condition: { field: string, operator: string, value: mixed }
|
||||
*
|
||||
* @param array<int, array{field: string, operator: string, value: mixed}> $conditions
|
||||
*/
|
||||
public function evaluate(array $conditions, DecisionEventContext $context): bool
|
||||
{
|
||||
foreach ($conditions as $condition) {
|
||||
if (! $this->evaluateOne($condition, $context)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function evaluateOne(array $condition, DecisionEventContext $context): bool
|
||||
{
|
||||
$field = $condition['field'] ?? '';
|
||||
$operator = $condition['operator'] ?? '=';
|
||||
$expected = $condition['value'] ?? null;
|
||||
|
||||
$actual = $this->resolveField($field, $context);
|
||||
|
||||
return $this->compare($actual, $operator, $expected);
|
||||
}
|
||||
|
||||
protected function resolveField(string $field, DecisionEventContext $context): mixed
|
||||
{
|
||||
return match ($field) {
|
||||
'activity.amount' => $context->activity?->amount,
|
||||
'activity.note' => $context->activity?->note,
|
||||
'contract.active' => $context->contract !== null ? (bool) $context->contract->active : null,
|
||||
'contract.account.balance_amount' => $this->resolveAccountBalance($context),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveAccountBalance(DecisionEventContext $context): mixed
|
||||
{
|
||||
if (! $context->contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context->contract->loadMissing('account');
|
||||
|
||||
return $context->contract->account?->balance_amount;
|
||||
}
|
||||
|
||||
protected function compare(mixed $actual, string $operator, mixed $expected): bool
|
||||
{
|
||||
if ($actual === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($operator, ['>', '>=', '<', '<='], true)) {
|
||||
$actual = (float) $actual;
|
||||
$expected = (float) $expected;
|
||||
}
|
||||
|
||||
return match ($operator) {
|
||||
'=' => $actual == $expected,
|
||||
'!=' => $actual != $expected,
|
||||
'>' => $actual > $expected,
|
||||
'>=' => $actual >= $expected,
|
||||
'<' => $actual < $expected,
|
||||
'<=' => $actual <= $expected,
|
||||
'contains' => str_contains((string) $actual, (string) $expected),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available condition field definitions for the frontend.
|
||||
*
|
||||
* @return array<int, array{key: string, label: string, type: string}>
|
||||
*/
|
||||
public static function availableFields(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'activity.amount', 'label' => 'Aktivnost – znesek', 'type' => 'numeric'],
|
||||
['key' => 'activity.note', 'label' => 'Aktivnost – opomba', 'type' => 'string'],
|
||||
['key' => 'contract.active', 'label' => 'Pogodba – aktivna', 'type' => 'boolean'],
|
||||
['key' => 'contract.account.balance_amount', 'label' => 'Račun – stanje', 'type' => 'numeric'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available operators grouped by field type.
|
||||
*
|
||||
* @return array<string, array<int, array{key: string, label: string}>>
|
||||
*/
|
||||
public static function availableOperators(): array
|
||||
{
|
||||
return [
|
||||
'numeric' => [
|
||||
['key' => '=', 'label' => 'je enako'],
|
||||
['key' => '!=', 'label' => 'ni enako'],
|
||||
['key' => '>', 'label' => 'je večje od'],
|
||||
['key' => '>=', 'label' => 'je večje ali enako'],
|
||||
['key' => '<', 'label' => 'je manjše od'],
|
||||
['key' => '<=', 'label' => 'je manjše ali enako'],
|
||||
],
|
||||
'string' => [
|
||||
['key' => '=', 'label' => 'je enako'],
|
||||
['key' => '!=', 'label' => 'ni enako'],
|
||||
['key' => 'contains', 'label' => 'vsebuje'],
|
||||
],
|
||||
'boolean' => [
|
||||
['key' => '=', 'label' => 'je'],
|
||||
['key' => '!=', 'label' => 'ni'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -36,14 +36,6 @@ public function handle(DecisionEventContext $context, array $config = []): void
|
|||
$setting->reactivate = (bool) $config['reactivate'];
|
||||
}
|
||||
|
||||
// Cancel all active FieldJobs for this contract before archiving (raw update to avoid boot-event side effects)
|
||||
\DB::table('field_jobs')
|
||||
->where('contract_id', $contractId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->whereNull('deleted_at')
|
||||
->update(['cancelled_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
$results = app(ArchiveExecutor::class)->executeSetting(
|
||||
$setting,
|
||||
['contract_id' => $contractId],
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DecisionEvents\Handlers;
|
||||
|
||||
use App\Models\CallLater;
|
||||
use App\Services\DecisionEvents\Contracts\DecisionEventHandler;
|
||||
use App\Services\DecisionEvents\DecisionEventContext;
|
||||
|
||||
class CallLaterHandler implements DecisionEventHandler
|
||||
{
|
||||
public function handle(DecisionEventContext $context, array $config = []): void
|
||||
{
|
||||
$activity = $context->activity;
|
||||
|
||||
if (empty($activity->call_back_at)) {
|
||||
return;
|
||||
}
|
||||
|
||||
CallLater::create([
|
||||
'activity_id' => $activity->id,
|
||||
'client_case_id' => $activity->client_case_id,
|
||||
'contract_id' => $activity->contract_id,
|
||||
'user_id' => $activity->user_id,
|
||||
'call_back_at' => $activity->call_back_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
1773
composer.lock
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->dateTime('call_back_at')->nullable()->after('due_date');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->dropColumn('call_back_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('call_laters', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete();
|
||||
$table->foreignId('client_case_id')->constrained('client_cases')->cascadeOnDelete();
|
||||
$table->foreignId('contract_id')->nullable()->constrained('contracts')->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->dateTime('call_back_at');
|
||||
$table->dateTime('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('call_laters');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('installments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('account_id')->constrained('accounts')->cascadeOnDelete();
|
||||
$table->decimal('amount', 20, 4);
|
||||
$table->decimal('balance_before', 20, 4)->nullable();
|
||||
$table->string('currency', 3)->default('EUR');
|
||||
$table->string('reference', 100)->nullable();
|
||||
$table->timestamp('installment_at')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('activity_id')->nullable()->constrained('activities')->nullOnDelete();
|
||||
$table->softDeletes();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('installments');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('installment_settings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('default_currency', 3)->default('EUR');
|
||||
$table->boolean('create_activity_on_installment')->default(false);
|
||||
$table->foreignId('default_decision_id')->nullable()->constrained('decisions')->nullOnDelete();
|
||||
$table->foreignId('default_action_id')->nullable()->constrained('actions')->nullOnDelete();
|
||||
$table->string('activity_note_template', 255)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('installment_settings');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('contract_settings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->boolean('create_activity_on_balance_change')->default(false);
|
||||
$table->foreignId('default_action_id')->nullable()->constrained('actions')->nullOnDelete();
|
||||
$table->foreignId('default_decision_id')->nullable()->constrained('decisions')->nullOnDelete();
|
||||
$table->string('activity_note_template', 255)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('contract_settings');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN assigned_at TYPE timestamp USING assigned_at::timestamp');
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN completed_at TYPE timestamp USING completed_at::timestamp');
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN cancelled_at TYPE timestamp USING cancelled_at::timestamp');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN assigned_at TYPE date USING assigned_at::date');
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN completed_at TYPE date USING completed_at::date');
|
||||
DB::statement('ALTER TABLE field_jobs ALTER COLUMN cancelled_at TYPE date USING cancelled_at::date');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('login_redirect')->nullable()->after('active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('login_redirect');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
180
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -11,9 +11,6 @@
|
|||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Pure">
|
||||
<directory>tests/Pure</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from "@tanstack/vue-table";
|
||||
import { valueUpdater } from "@/lib/utils";
|
||||
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
|
||||
import DataTablePaginationClient from "./DataTablePaginationClient.vue";
|
||||
import DataTablePagination from "./DataTablePagination.vue";
|
||||
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
||||
import DataTableToolbar from "./DataTableToolbar.vue";
|
||||
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
|
||||
|
|
@ -618,14 +618,7 @@ defineExpose({
|
|||
|
||||
<!-- Client-side pagination -->
|
||||
<template v-else>
|
||||
<DataTablePaginationClient
|
||||
:current-page="table.getState().pagination.pageIndex"
|
||||
:last-page="table.getPageCount()"
|
||||
:total="table.getFilteredRowModel().rows.length"
|
||||
:showing-from="table.getFilteredSelectedRowModel().rows.length"
|
||||
:showing-to="table.getFilteredRowModel().rows.length"
|
||||
:table="table"
|
||||
/>
|
||||
<DataTablePagination :table="table" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ const props = defineProps({
|
|||
showGoto: { type: Boolean, default: true },
|
||||
maxPageLinks: { type: Number, default: 5 },
|
||||
perPage: { type: Number, default: 10 },
|
||||
table: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:page"]);
|
||||
|
|
@ -35,7 +34,7 @@ function goToPageInput() {
|
|||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return;
|
||||
const target = Math.max(1, Math.min(props.lastPage, Math.floor(n)));
|
||||
if (target !== props.currentPage) props.table.setPageIndex(target - 1);
|
||||
if (target !== props.currentPage) setPage(target);
|
||||
gotoInput.value = "";
|
||||
}
|
||||
|
||||
|
|
@ -137,17 +136,14 @@ function setPage(p) {
|
|||
>
|
||||
<PaginationContent>
|
||||
<!-- First -->
|
||||
<PaginationFirst
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.setPageIndex(0)"
|
||||
>
|
||||
<PaginationFirst :disabled="currentPage <= 1" @click="setPage(1)">
|
||||
<ChevronsLeft />
|
||||
</PaginationFirst>
|
||||
|
||||
<!-- Previous -->
|
||||
<PaginationPrevious
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="setPage(currentPage - 1)"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</PaginationPrevious>
|
||||
|
|
@ -158,22 +154,25 @@ function setPage(p) {
|
|||
<PaginationItem
|
||||
v-else
|
||||
:value="item"
|
||||
:is-active="currentPage === index"
|
||||
@click="table.setPageIndex(index)"
|
||||
:is-active="currentPage === item"
|
||||
@click="setPage(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</PaginationItem>
|
||||
</template>
|
||||
|
||||
<!-- Next -->
|
||||
<PaginationNext :disabled="!table.getCanNextPage()" @click="table.nextPage()">
|
||||
<PaginationNext
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="setPage(currentPage + 1)"
|
||||
>
|
||||
<ChevronRight />
|
||||
</PaginationNext>
|
||||
|
||||
<!-- Last -->
|
||||
<PaginationLast
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="setPage(lastPage)"
|
||||
>
|
||||
<ChevronsRight />
|
||||
</PaginationLast>
|
||||
|
|
@ -192,7 +191,7 @@ function setPage(p) {
|
|||
:max="lastPage"
|
||||
inputmode="numeric"
|
||||
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
:placeholder="String(currentPage + 1)"
|
||||
:placeholder="String(currentPage)"
|
||||
aria-label="Pojdi na stran"
|
||||
@keyup.enter="goToPageInput"
|
||||
@blur="goToPageInput"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
import { computed, ref, useAttrs } from "vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Calendar } from "@/Components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/Components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarIcon } from "lucide-vue-next";
|
||||
import { format } from "date-fns";
|
||||
|
|
@ -82,9 +86,7 @@ const toCalendarDate = (value) => {
|
|||
// Convert CalendarDate to ISO string (YYYY-MM-DD)
|
||||
const fromCalendarDate = (calendarDate) => {
|
||||
if (!calendarDate) return null;
|
||||
return `${String(calendarDate.year).padStart(4, "0")}-${String(
|
||||
calendarDate.month
|
||||
).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
|
||||
return `${String(calendarDate.year).padStart(4, "0")}-${String(calendarDate.month).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const calendarDate = computed({
|
||||
|
|
@ -140,10 +142,11 @@ const open = ref(false);
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="start">
|
||||
<Calendar locale="si-SI" v-model="calendarDate" :disabled="disabled" />
|
||||
<Calendar v-model="calendarDate" :disabled="disabled" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p v-if="error" class="mt-1 text-sm text-red-600">
|
||||
{{ Array.isArray(error) ? error[0] : error }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -26,141 +26,6 @@ const loading = ref(false);
|
|||
const previewGenerating = ref(false);
|
||||
const previewError = ref("");
|
||||
|
||||
// Image viewer – zoom & pan state
|
||||
const containerRef = ref(null);
|
||||
const imageRef = ref(null);
|
||||
const imageScale = ref(1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
const fitScale = ref(1);
|
||||
const isDragging = ref(false);
|
||||
const hasMoved = ref(false);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const dragStartTX = ref(0);
|
||||
const dragStartTY = ref(0);
|
||||
|
||||
const MAX_SCALE = 8;
|
||||
|
||||
const imageCursorClass = computed(() => {
|
||||
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
|
||||
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
|
||||
return "cursor-default";
|
||||
});
|
||||
|
||||
const initImageView = () => {
|
||||
const container = containerRef.value;
|
||||
const img = imageRef.value;
|
||||
if (!container || !img) return;
|
||||
const cW = container.clientWidth;
|
||||
const cH = container.clientHeight;
|
||||
const iW = img.naturalWidth || cW;
|
||||
const iH = img.naturalHeight || cH;
|
||||
const fs = Math.min(1, cW / iW, cH / iH);
|
||||
fitScale.value = fs;
|
||||
imageScale.value = fs;
|
||||
translateX.value = (cW - iW * fs) / 2;
|
||||
translateY.value = (cH - iH * fs) / 2;
|
||||
};
|
||||
|
||||
const resetImageView = () => {
|
||||
initImageView();
|
||||
};
|
||||
|
||||
const clampTranslate = (tx, ty, scale) => {
|
||||
const container = containerRef.value;
|
||||
const img = imageRef.value;
|
||||
if (!container || !img) return { tx, ty };
|
||||
const cW = container.clientWidth;
|
||||
const cH = container.clientHeight;
|
||||
const iW = img.naturalWidth * scale;
|
||||
const iH = img.naturalHeight * scale;
|
||||
// When image fills the container: clamp so image edges stay within container.
|
||||
// When image is smaller than container: keep it centered.
|
||||
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
|
||||
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
|
||||
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
|
||||
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
|
||||
return {
|
||||
tx: Math.min(maxX, Math.max(minX, tx)),
|
||||
ty: Math.min(maxY, Math.max(minY, ty)),
|
||||
};
|
||||
};
|
||||
|
||||
const zoomAt = (mx, my, factor) => {
|
||||
const img = imageRef.value;
|
||||
const iW = img?.naturalWidth ?? 1;
|
||||
const iH = img?.naturalHeight ?? 1;
|
||||
const raw = imageScale.value * factor;
|
||||
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
|
||||
if (newScale === imageScale.value) return;
|
||||
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
|
||||
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
|
||||
const clamped = clampTranslate(tx, ty, newScale);
|
||||
translateX.value = clamped.tx;
|
||||
translateY.value = clamped.ty;
|
||||
imageScale.value = newScale;
|
||||
};
|
||||
|
||||
const mousePos = (e) => {
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
initImageView();
|
||||
};
|
||||
|
||||
const handleWheel = (e) => {
|
||||
e.preventDefault();
|
||||
const { mx, my } = mousePos(e);
|
||||
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDragging.value) return;
|
||||
const dx = e.clientX - dragStartX.value;
|
||||
const dy = e.clientY - dragStartY.value;
|
||||
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
||||
hasMoved.value = true;
|
||||
}
|
||||
if (hasMoved.value) {
|
||||
const clamped = clampTranslate(
|
||||
dragStartTX.value + dx,
|
||||
dragStartTY.value + dy,
|
||||
imageScale.value
|
||||
);
|
||||
translateX.value = clamped.tx;
|
||||
translateY.value = clamped.ty;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isDragging.value = false;
|
||||
setTimeout(() => {
|
||||
hasMoved.value = false;
|
||||
}, 0);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
isDragging.value = true;
|
||||
hasMoved.value = false;
|
||||
dragStartX.value = e.clientX;
|
||||
dragStartY.value = e.clientY;
|
||||
dragStartTX.value = translateX.value;
|
||||
dragStartTY.value = translateY.value;
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
if (props.filename) {
|
||||
return props.filename.split(".").pop()?.toLowerCase() || "";
|
||||
|
|
@ -253,10 +118,6 @@ watch(
|
|||
previewGenerating.value = false;
|
||||
previewError.value = "";
|
||||
docxPreviewUrl.value = "";
|
||||
imageScale.value = 1;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
fitScale.value = 1;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
|
@ -318,51 +179,11 @@ watch(
|
|||
|
||||
<!-- Image Viewer -->
|
||||
<template v-else-if="viewerType === 'image' && props.src">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative h-full overflow-hidden select-none"
|
||||
:class="imageCursorClass"
|
||||
@mousedown="handleMouseDown"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<img
|
||||
ref="imageRef"
|
||||
:src="props.src"
|
||||
:alt="props.title"
|
||||
draggable="false"
|
||||
class="absolute top-0 left-0 max-w-none"
|
||||
:style="{
|
||||
transformOrigin: '0 0',
|
||||
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.12s ease',
|
||||
}"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
<!-- Zoom level badge -->
|
||||
<div
|
||||
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
|
||||
>
|
||||
{{ Math.round(imageScale * 100) }}%
|
||||
</div>
|
||||
<!-- Reset button -->
|
||||
<Button
|
||||
v-if="imageScale > fitScale + 0.01"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
|
||||
title="Ponastavi pogled"
|
||||
@click.stop="resetImageView"
|
||||
>
|
||||
<RotateCcwIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
<!-- Hint -->
|
||||
<div
|
||||
v-if="imageScale <= fitScale + 0.01"
|
||||
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
|
||||
>
|
||||
Kolesce za povečavo / pomanjšavo · Povleči za premik
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
:src="props.src"
|
||||
:alt="props.title"
|
||||
class="max-w-full max-h-full mx-auto object-contain"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Text/CSV/XML Viewer -->
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { computed, ref, watch } from "vue";
|
|||
import { useForm, Field as FormField } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { router, usePage } from "@inertiajs/vue3";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
|
|
@ -28,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>
|
||||
|
|
|
|||
|
|
@ -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 (UCS‑2). V tem
|
||||
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
|
||||
sporočilih 67 znakov na del), medtem ko je pri GSM‑7 160 znakov (pri daljših
|
||||
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in €)
|
||||
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM‑7) oziroma
|
||||
320 (UCS‑2) znakov.
|
||||
<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 (UCS‑2). V tem
|
||||
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
|
||||
sporočilih 67 znakov na del), medtem ko je pri GSM‑7 160 znakov (pri daljših
|
||||
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in €)
|
||||
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM‑7) oziroma
|
||||
320 (UCS‑2) znakov.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="delivery_report">
|
||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Switch :model-value="value" @update:model-value="handleChange" />
|
||||
</FormControl>
|
||||
<div class="space-y-1 leading-none">
|
||||
<FormLabel>Zahtevaj poročilo o dostavi</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeSmsDialog" :disabled="processing">
|
||||
|
|
|
|||
|
|
@ -84,8 +84,8 @@ const summaryText = computed(() => {
|
|||
const found = props.items.find((i) => String(i.value) === String(v));
|
||||
return found?.label || v;
|
||||
});
|
||||
if (labels.length <= 3) return labels.join(", ");
|
||||
const firstThree = labels.slice(0, 3).join(", ");
|
||||
if (labels.length <= 3) return labels.join(', ');
|
||||
const firstThree = labels.slice(0, 3).join(', ');
|
||||
const remaining = labels.length - 3;
|
||||
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
|
||||
});
|
||||
|
|
@ -154,7 +154,7 @@ const summaryText = computed(() => {
|
|||
:variant="chipVariant"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span class="truncate max-w-35">
|
||||
<span class="truncate max-w-[140px]">
|
||||
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
|
||||
</span>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
v-bind="forwarded"
|
||||
:class="cn('z-50 min-w-32 overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md max-h-[var(--reka-dropdown-menu-content-available-height)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
||||
:class="cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
|
||||
/**
|
||||
* Composable for infinite scroll with Inertia v2.
|
||||
*
|
||||
* @param {Function} getProp - () => the current paginator object from Inertia props
|
||||
* @param {string} propName - the prop key name to reload
|
||||
* @param {string} pageParam - query string parameter name for page number
|
||||
* @param {Function} getRouteUrl - () => current URL to reload
|
||||
*/
|
||||
export function useInfiniteList(getProp, propName, pageParam, getRouteUrl) {
|
||||
const items = ref([]);
|
||||
const currentPage = ref(1);
|
||||
const lastPage = ref(1);
|
||||
const isLoadingMore = ref(false);
|
||||
const sentinelRef = ref(null);
|
||||
let observer = null;
|
||||
|
||||
function syncFromProp() {
|
||||
const prop = getProp();
|
||||
if (!prop) return;
|
||||
lastPage.value = prop.last_page ?? 1;
|
||||
}
|
||||
|
||||
function appendFromProp() {
|
||||
const prop = getProp();
|
||||
if (!prop?.data) return;
|
||||
// append only new items (avoid duplicates by id)
|
||||
const existingIds = new Set(items.value.map((i) => i.id));
|
||||
const newItems = prop.data.filter((i) => !existingIds.has(i.id));
|
||||
items.value.push(...newItems);
|
||||
}
|
||||
|
||||
function reset(initialProp) {
|
||||
items.value = initialProp?.data ?? [];
|
||||
currentPage.value = initialProp?.current_page ?? 1;
|
||||
lastPage.value = initialProp?.last_page ?? 1;
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (isLoadingMore.value) return;
|
||||
if (currentPage.value >= lastPage.value) return;
|
||||
|
||||
const nextPage = currentPage.value + 1;
|
||||
isLoadingMore.value = true;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(pageParam, nextPage);
|
||||
|
||||
router.reload({
|
||||
url: `${window.location.pathname}?${params.toString()}`,
|
||||
only: [propName],
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onSuccess: () => {
|
||||
appendFromProp();
|
||||
currentPage.value = nextPage;
|
||||
isLoadingMore.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
isLoadingMore.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
if (sentinelRef.value) {
|
||||
observer.observe(sentinelRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
currentPage,
|
||||
lastPage,
|
||||
isLoadingMore,
|
||||
sentinelRef,
|
||||
reset,
|
||||
syncFromProp,
|
||||
appendFromProp,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
|
|
@ -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"],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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() || "" }},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
—
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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']"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -3,4 +3,3 @@
|
|||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class)->in('Feature', 'Unit');
|
||||
uses(\PHPUnit\Framework\TestCase::class)->in('Pure');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user