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\Models\SmsTemplate;
|
||||||
use App\Services\Contact\PhoneSelector;
|
use App\Services\Contact\PhoneSelector;
|
||||||
use App\Services\Sms\SmsService;
|
use App\Services\Sms\SmsService;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
|
@ -29,7 +30,7 @@ public function index(Request $request): Response
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|
||||||
return Inertia::render('Packages/Index', [
|
return Inertia::render('Admin/Packages/Index', [
|
||||||
'packages' => $packages,
|
'packages' => $packages,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +70,7 @@ public function create(Request $request): Response
|
||||||
})
|
})
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Packages/Create', [
|
return Inertia::render('Admin/Packages/Create', [
|
||||||
'profiles' => $profiles,
|
'profiles' => $profiles,
|
||||||
'senders' => $senders,
|
'senders' => $senders,
|
||||||
'templates' => $templates,
|
'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,
|
'package' => $package,
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
'preview' => $preview,
|
'preview' => $preview,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
Gate::authorize('manage-settings');
|
Gate::authorize('manage-settings');
|
||||||
|
|
||||||
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active', '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']);
|
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
|
||||||
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
|
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
|
||||||
|
|
||||||
|
|
@ -73,17 +73,4 @@ public function toggleActive(User $user): RedirectResponse
|
||||||
|
|
||||||
return back()->with('success', "Uporabnik {$status}");
|
return back()->with('success', "Uporabnik {$status}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateSettings(Request $request, User $user): RedirectResponse
|
|
||||||
{
|
|
||||||
Gate::authorize('manage-settings');
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
|
||||||
'login_redirect' => ['nullable', 'string', 'max:255'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->update($validated);
|
|
||||||
|
|
||||||
return back()->with('success', 'Nastavitve shranjene');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
$que->whereDate('client_cases.created_at', '<=', $to);
|
||||||
})
|
})
|
||||||
->groupBy('client_cases.id')
|
->groupBy('client_cases.id')
|
||||||
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
|
->addSelect([
|
||||||
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN 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'])
|
->with(['person.client', 'client.person'])
|
||||||
->orderByDesc('client_cases.created_at');
|
->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'));
|
return back()->with('warning', __('contracts.edit_not_allowed_archived'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$balanceChanged = false;
|
\DB::transaction(function () use ($request, $contract) {
|
||||||
$oldBalance = null;
|
|
||||||
$newBalance = null;
|
|
||||||
|
|
||||||
\DB::transaction(function () use ($request, $contract, &$balanceChanged, &$oldBalance, &$newBalance) {
|
|
||||||
$contract->update([
|
$contract->update([
|
||||||
'reference' => $request->input('reference'),
|
'reference' => $request->input('reference'),
|
||||||
'type_id' => $request->input('type_id'),
|
'type_id' => $request->input('type_id'),
|
||||||
|
|
@ -256,7 +254,6 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
||||||
$accountData['type_id'] = $request->input('account_type_id');
|
$accountData['type_id'] = $request->input('account_type_id');
|
||||||
}
|
}
|
||||||
if ($currentAccount) {
|
if ($currentAccount) {
|
||||||
$oldBalance = (float) $currentAccount->balance_amount;
|
|
||||||
$currentAccount->update($accountData);
|
$currentAccount->update($accountData);
|
||||||
if (array_key_exists('balance_amount', $accountData)) {
|
if (array_key_exists('balance_amount', $accountData)) {
|
||||||
$currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save();
|
$currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save();
|
||||||
|
|
@ -267,10 +264,6 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
|
||||||
->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]);
|
->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]);
|
||||||
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
||||||
}
|
}
|
||||||
$newBalance = $freshBal;
|
|
||||||
if ($oldBalance !== $freshBal) {
|
|
||||||
$balanceChanged = true;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
// Preserve segment filter if present
|
||||||
$segment = request('segment');
|
$segment = request('segment');
|
||||||
|
|
||||||
|
|
@ -334,7 +306,6 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||||
try {
|
try {
|
||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'due_date' => 'nullable|date',
|
'due_date' => 'nullable|date',
|
||||||
'call_back_at' => 'nullable|date_format:Y-m-d H:i:s|after_or_equal:now',
|
|
||||||
'amount' => 'nullable|decimal:0,4',
|
'amount' => 'nullable|decimal:0,4',
|
||||||
'note' => 'nullable|string',
|
'note' => 'nullable|string',
|
||||||
'action_id' => 'exists:\App\Models\Action,id',
|
'action_id' => 'exists:\App\Models\Action,id',
|
||||||
|
|
@ -355,14 +326,14 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||||
|
|
||||||
// Determine which contracts to process
|
// Determine which contracts to process
|
||||||
$contractIds = [];
|
$contractIds = [];
|
||||||
if ($createForAll && ! empty($contractUuids)) {
|
if ($createForAll && !empty($contractUuids)) {
|
||||||
// Get all contract IDs from the provided UUIDs
|
// Get all contract IDs from the provided UUIDs
|
||||||
$contracts = Contract::withTrashed()
|
$contracts = Contract::withTrashed()
|
||||||
->whereIn('uuid', $contractUuids)
|
->whereIn('uuid', $contractUuids)
|
||||||
->where('client_case_id', $clientCase->id)
|
->where('client_case_id', $clientCase->id)
|
||||||
->get();
|
->get();
|
||||||
$contractIds = $contracts->pluck('id')->toArray();
|
$contractIds = $contracts->pluck('id')->toArray();
|
||||||
} elseif (! empty($contractUuids) && isset($contractUuids[0])) {
|
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
|
||||||
// Single contract mode
|
// Single contract mode
|
||||||
$contract = Contract::withTrashed()
|
$contract = Contract::withTrashed()
|
||||||
->where('uuid', $contractUuids[0])
|
->where('uuid', $contractUuids[0])
|
||||||
|
|
@ -371,7 +342,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||||
if ($contract) {
|
if ($contract) {
|
||||||
$contractIds = [$contract->id];
|
$contractIds = [$contract->id];
|
||||||
}
|
}
|
||||||
} elseif (! empty($attributes['contract_uuid'])) {
|
} elseif (!empty($attributes['contract_uuid'])) {
|
||||||
// Legacy single contract_uuid support
|
// Legacy single contract_uuid support
|
||||||
$contract = Contract::withTrashed()
|
$contract = Contract::withTrashed()
|
||||||
->where('uuid', $attributes['contract_uuid'])
|
->where('uuid', $attributes['contract_uuid'])
|
||||||
|
|
@ -400,7 +371,6 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||||
// Create activity
|
// Create activity
|
||||||
$row = $clientCase->activities()->create([
|
$row = $clientCase->activities()->create([
|
||||||
'due_date' => $attributes['due_date'] ?? null,
|
'due_date' => $attributes['due_date'] ?? null,
|
||||||
'call_back_at' => $attributes['call_back_at'] ?? null,
|
|
||||||
'amount' => $attributes['amount'] ?? null,
|
'amount' => $attributes['amount'] ?? null,
|
||||||
'note' => $attributes['note'] ?? null,
|
'note' => $attributes['note'] ?? null,
|
||||||
'action_id' => $attributes['action_id'],
|
'action_id' => $attributes['action_id'],
|
||||||
|
|
@ -855,8 +825,9 @@ public function show(ClientCase $clientCase)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get contracts using service
|
// Get contracts using service
|
||||||
$contracts = $this->caseDataService->getContracts($case, $segmentId);
|
$contractsPerPage = request()->integer('contracts_per_page', 10);
|
||||||
$contractIds = collect($contracts)->pluck('id')->all();
|
$contracts = $this->caseDataService->getContracts($case, $segmentId, $contractsPerPage);
|
||||||
|
$contractIds = collect($contracts->items())->pluck('id')->all();
|
||||||
|
|
||||||
// Get activities using service
|
// Get activities using service
|
||||||
$activitiesPerPage = request()->integer('activities_per_page', 15);
|
$activitiesPerPage = request()->integer('activities_per_page', 15);
|
||||||
|
|
@ -897,14 +868,11 @@ public function show(ClientCase $clientCase)
|
||||||
'decisions.emailTemplate' => function ($q) {
|
'decisions.emailTemplate' => function ($q) {
|
||||||
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
||||||
},
|
},
|
||||||
'decisions.events' => function ($q) {
|
|
||||||
$q->select('events.id', 'events.key', 'events.name');
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
->get(['id', 'name', 'color_tag', 'segment_id']),
|
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||||
'types' => $types,
|
'types' => $types,
|
||||||
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
|
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
|
||||||
'all_segments' => Segment::query()->where('active', true)->get(['id', 'name']),
|
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
|
||||||
'current_segment' => $currentSegment,
|
'current_segment' => $currentSegment,
|
||||||
'sms_profiles' => \App\Models\SmsProfile::query()
|
'sms_profiles' => \App\Models\SmsProfile::query()
|
||||||
->select(['id', 'name', 'default_sender_id'])
|
->select(['id', 'name', 'default_sender_id'])
|
||||||
|
|
@ -913,15 +881,14 @@ public function show(ClientCase $clientCase)
|
||||||
->get(),
|
->get(),
|
||||||
'sms_senders' => \App\Models\SmsSender::query()
|
'sms_senders' => \App\Models\SmsSender::query()
|
||||||
->select(['id', 'profile_id'])
|
->select(['id', 'profile_id'])
|
||||||
->selectRaw('sname as name')
|
->addSelect(\DB::raw('sname as name'))
|
||||||
->selectRaw('phone_number as phone')
|
->addSelect(\DB::raw('phone_number as phone'))
|
||||||
->orderBy('sname')
|
->orderBy('sname')
|
||||||
->get(),
|
->get(),
|
||||||
'sms_templates' => \App\Models\SmsTemplate::query()
|
'sms_templates' => \App\Models\SmsTemplate::query()
|
||||||
->select(['id', 'name', 'content', 'allow_custom_body'])
|
->select(['id', 'name', 'content', 'allow_custom_body'])
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(),
|
->get(),
|
||||||
'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) {
|
if (! $setting) {
|
||||||
\Log::warning('No archive settings found for batch archive');
|
\Log::warning('No archive settings found for batch archive');
|
||||||
|
|
||||||
return back()->with('flash', [
|
return back()->with('flash', [
|
||||||
'error' => 'No archive settings found',
|
'error' => 'No archive settings found',
|
||||||
]);
|
]);
|
||||||
|
|
@ -1151,9 +1117,8 @@ public function archiveBatch(Request $request)
|
||||||
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
||||||
|
|
||||||
// Skip if contract is already archived (active = 0)
|
// Skip if contract is already archived (active = 0)
|
||||||
if (! $contract->active) {
|
if (!$contract->active) {
|
||||||
$skippedCount++;
|
$skippedCount++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1243,7 +1208,7 @@ public function archiveBatch(Request $request)
|
||||||
if ($skippedCount > 0) {
|
if ($skippedCount > 0) {
|
||||||
$message .= ", skipped $skippedCount already archived";
|
$message .= ", skipped $skippedCount already archived";
|
||||||
}
|
}
|
||||||
$message .= ', '.count($errors).' failed';
|
$message .= ", " . count($errors) . " failed";
|
||||||
|
|
||||||
return back()->with('flash', [
|
return back()->with('flash', [
|
||||||
'error' => $message,
|
'error' => $message,
|
||||||
|
|
@ -1380,10 +1345,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
||||||
if (! empty($validated['sender_id'])) {
|
if (! empty($validated['sender_id'])) {
|
||||||
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
|
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
|
||||||
if (! $sender) {
|
if (! $sender) {
|
||||||
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
||||||
}
|
}
|
||||||
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
|
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
|
||||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (! $profile) {
|
if (! $profile) {
|
||||||
|
|
@ -1426,7 +1391,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create an activity before sending
|
// Create an activity before sending
|
||||||
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
||||||
$activityData = [
|
$activityData = [
|
||||||
'note' => $activityNote,
|
'note' => $activityNote,
|
||||||
'user_id' => optional($request->user())->id,
|
'user_id' => optional($request->user())->id,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ public function index(Client $client, Request $request)
|
||||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||||
->groupBy('clients.id');
|
->groupBy('clients.id');
|
||||||
})
|
})
|
||||||
// ->where('clients.active', 1)
|
//->where('clients.active', 1)
|
||||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||||
->leftJoin('contracts', function ($join) {
|
->leftJoin('contracts', function ($join) {
|
||||||
|
|
@ -40,8 +40,12 @@ public function index(Client $client, Request $request)
|
||||||
})
|
})
|
||||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||||
->groupBy('clients.id')
|
->groupBy('clients.id')
|
||||||
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count')
|
->addSelect([
|
||||||
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
// 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')
|
->with('person')
|
||||||
->orderByDesc('clients.created_at');
|
->orderByDesc('clients.created_at');
|
||||||
|
|
||||||
|
|
@ -67,7 +71,6 @@ public function show(Client $client, Request $request)
|
||||||
|
|
||||||
return Inertia::render('Client/Show', [
|
return Inertia::render('Client/Show', [
|
||||||
'client' => $data,
|
'client' => $data,
|
||||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
|
||||||
'client_cases' => $data->clientCases()
|
'client_cases' => $data->clientCases()
|
||||||
->select('client_cases.*')
|
->select('client_cases.*')
|
||||||
->when($request->input('search'), function ($que, $search) {
|
->when($request->input('search'), function ($que, $search) {
|
||||||
|
|
@ -85,8 +88,10 @@ public function show(Client $client, Request $request)
|
||||||
})
|
})
|
||||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||||
->groupBy('client_cases.id')
|
->groupBy('client_cases.id')
|
||||||
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
|
->addSelect([
|
||||||
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
|
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN 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'])
|
->with(['person', 'client.person'])
|
||||||
->where('client_cases.active', 1)
|
->where('client_cases.active', 1)
|
||||||
->orderByDesc('client_cases.created_at')
|
->orderByDesc('client_cases.created_at')
|
||||||
|
|
@ -157,7 +162,6 @@ public function contracts(Client $client, Request $request)
|
||||||
|
|
||||||
return Inertia::render('Client/Contracts', [
|
return Inertia::render('Client/Contracts', [
|
||||||
'client' => $data,
|
'client' => $data,
|
||||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
|
||||||
'contracts' => $contractsQuery
|
'contracts' => $contractsQuery
|
||||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
||||||
->withQueryString(),
|
->withQueryString(),
|
||||||
|
|
|
||||||
|
|
@ -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 App\Services\Sms\SmsService;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
|
|
@ -79,14 +80,14 @@ public function __invoke(SmsService $sms): Response
|
||||||
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
||||||
|
|
||||||
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||||
->selectRaw("DATE(COALESCE(assigned_at, created_at) AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
|
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c', 'd');
|
->pluck('c', 'd');
|
||||||
|
|
||||||
// Completed field jobs last 7 days
|
// Completed field jobs last 7 days
|
||||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||||
->whereBetween('completed_at', [$start, $end])
|
->whereBetween('completed_at', [$start, $end])
|
||||||
->selectRaw("DATE(completed_at AT TIME ZONE 'Europe/Ljubljana') as d, COUNT(*) as c")
|
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c', 'd');
|
->pluck('c', 'd');
|
||||||
|
|
||||||
|
|
@ -100,13 +101,13 @@ public function __invoke(SmsService $sms): Response
|
||||||
// Field jobs assigned today - cached
|
// Field jobs assigned today - cached
|
||||||
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||||
return FieldJob::query()
|
return FieldJob::query()
|
||||||
->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'])
|
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||||
->with(['contract' => function ($q) {
|
->with(['contract' => function ($q) {
|
||||||
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||||
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
||||||
}])
|
}])
|
||||||
->orderByRaw('COALESCE(assigned_at, created_at) DESC')
|
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
||||||
->limit(15)
|
->limit(15)
|
||||||
->get()
|
->get()
|
||||||
->map(function ($fj) {
|
->map(function ($fj) {
|
||||||
|
|
@ -119,26 +120,20 @@ public function __invoke(SmsService $sms): Response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $contract) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $fj->id,
|
'id' => $fj->id,
|
||||||
'priority' => $fj->priority,
|
'priority' => $fj->priority,
|
||||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||||
'created_at' => $fj->created_at?->toIso8601String(),
|
'created_at' => $fj->created_at?->toIso8601String(),
|
||||||
'contract' => [
|
'contract' => $contract ? [
|
||||||
'uuid' => $contract->uuid,
|
'uuid' => $contract->uuid,
|
||||||
'reference' => $contract->reference,
|
'reference' => $contract->reference,
|
||||||
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||||
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||||
'segment_id' => $segmentId,
|
'segment_id' => $segmentId,
|
||||||
],
|
] : null,
|
||||||
];
|
];
|
||||||
})
|
});
|
||||||
->filter()
|
|
||||||
->values();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// System health for timestamp
|
// System health for timestamp
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ public function index(Request $request)
|
||||||
'current_page' => $paginator->currentPage(),
|
'current_page' => $paginator->currentPage(),
|
||||||
'from' => $paginator->firstItem(),
|
'from' => $paginator->firstItem(),
|
||||||
'last_page' => $paginator->lastPage(),
|
'last_page' => $paginator->lastPage(),
|
||||||
'links' => $paginator->linkCollection()->toArray(),
|
|
||||||
'path' => $paginator->path(),
|
'path' => $paginator->path(),
|
||||||
'per_page' => $paginator->perPage(),
|
'per_page' => $paginator->perPage(),
|
||||||
'to' => $paginator->lastItem(),
|
'to' => $paginator->lastItem(),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\BankAccount;
|
||||||
use App\Models\Person\Person;
|
use App\Models\Person\Person;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
|
@ -21,14 +22,14 @@ public function update(Person $person, Request $request)
|
||||||
'tax_number' => 'nullable|integer',
|
'tax_number' => 'nullable|integer',
|
||||||
'social_security_number' => 'nullable|integer',
|
'social_security_number' => 'nullable|integer',
|
||||||
'description' => 'nullable|string|max:500',
|
'description' => 'nullable|string|max:500',
|
||||||
'employer' => 'nullable|string|max:255',
|
|
||||||
'birthday' => 'nullable|date',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$person->update($attributes);
|
$person->update($attributes);
|
||||||
|
|
||||||
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createAddress(Person $person, Request $request)
|
public function createAddress(Person $person, Request $request)
|
||||||
|
|
@ -79,6 +80,7 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
||||||
$address = $person->addresses()->findOrFail($address_id);
|
$address = $person->addresses()->findOrFail($address_id);
|
||||||
$address->delete(); // soft delete
|
$address->delete(); // soft delete
|
||||||
|
|
||||||
|
|
||||||
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,14 +142,8 @@ public function createEmail(Person $person, Request $request)
|
||||||
'verified_at' => 'nullable|date',
|
'verified_at' => 'nullable|date',
|
||||||
'preferences' => 'nullable|array',
|
'preferences' => 'nullable|array',
|
||||||
'meta' => 'nullable|array',
|
'meta' => 'nullable|array',
|
||||||
'decision_ids' => 'nullable|array',
|
|
||||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
|
||||||
unset($attributes['decision_ids']);
|
|
||||||
$attributes['preferences'] = array_merge($attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
|
||||||
|
|
||||||
// Dedup: avoid duplicate email per person by value
|
// Dedup: avoid duplicate email per person by value
|
||||||
$email = $person->emails()->firstOrCreate([
|
$email = $person->emails()->firstOrCreate([
|
||||||
'value' => $attributes['value'],
|
'value' => $attributes['value'],
|
||||||
|
|
@ -168,16 +164,10 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
||||||
'verified_at' => 'nullable|date',
|
'verified_at' => 'nullable|date',
|
||||||
'preferences' => 'nullable|array',
|
'preferences' => 'nullable|array',
|
||||||
'meta' => 'nullable|array',
|
'meta' => 'nullable|array',
|
||||||
'decision_ids' => 'nullable|array',
|
|
||||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$email = $person->emails()->findOrFail($email_id);
|
$email = $person->emails()->findOrFail($email_id);
|
||||||
|
|
||||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
|
||||||
unset($attributes['decision_ids']);
|
|
||||||
$attributes['preferences'] = array_merge($email->preferences ?? [], $attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
|
||||||
|
|
||||||
$email->update($attributes);
|
$email->update($attributes);
|
||||||
|
|
||||||
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
||||||
|
|
@ -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
|
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||||
$trr = $person->bankAccounts()->create($attributes);
|
$trr = $person->bankAccounts()->create($attributes);
|
||||||
|
|
||||||
|
|
||||||
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateTrr(Person $person, int $trr_id, Request $request)
|
public function updateTrr(Person $person, int $trr_id, Request $request)
|
||||||
|
|
@ -246,6 +238,7 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
||||||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||||
$trr->delete();
|
$trr->delete();
|
||||||
|
|
||||||
|
|
||||||
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,19 @@
|
||||||
class PhoneViewController extends Controller
|
class PhoneViewController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||||
|
public function index(Request $request)
|
||||||
public function index(Request $request): \Inertia\Response
|
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$userId = $request->user()->id;
|
||||||
$search = $request->input('search');
|
$search = $request->input('search');
|
||||||
$clientFilter = $request->input('client');
|
$clientFilter = $request->input('client');
|
||||||
|
$perPage = $request->integer('per_page', 15);
|
||||||
|
$perPage = max(1, min(100, $perPage));
|
||||||
|
|
||||||
$eagerLoad = [
|
$query = FieldJob::query()
|
||||||
|
->where('assigned_user_id', $userId)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->with([
|
||||||
'contract' => function ($q) {
|
'contract' => function ($q) {
|
||||||
$q->with([
|
$q->with([
|
||||||
'type:id,name',
|
'type:id,name',
|
||||||
|
|
@ -28,22 +33,19 @@ public function index(Request $request): \Inertia\Response
|
||||||
'clientCase.client.person:id,full_name',
|
'clientCase.client.person:id,full_name',
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
];
|
])
|
||||||
|
->orderByDesc('assigned_at');
|
||||||
$baseQuery = FieldJob::query()
|
|
||||||
->where('assigned_user_id', $userId)
|
|
||||||
->whereNull('completed_at')
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->with($eagerLoad);
|
|
||||||
|
|
||||||
|
// Apply client filter
|
||||||
if ($clientFilter) {
|
if ($clientFilter) {
|
||||||
$baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||||
$q->where('uuid', $clientFilter);
|
$q->where('uuid', $clientFilter);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
if ($search) {
|
if ($search) {
|
||||||
$baseQuery->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->whereHas('contract', function ($cq) use ($search) {
|
$q->whereHas('contract', function ($cq) use ($search) {
|
||||||
$cq->where('reference', 'ilike', '%'.$search.'%')
|
$cq->where('reference', 'ilike', '%'.$search.'%')
|
||||||
->orWhereHas('clientCase.person', function ($pq) use ($search) {
|
->orWhereHas('clientCase.person', function ($pq) use ($search) {
|
||||||
|
|
@ -56,14 +58,9 @@ public function index(Request $request): \Inertia\Response
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$pendingQuery = (clone $baseQuery)
|
$jobs = $query->paginate($perPage)->withQueryString();
|
||||||
->where(fn ($q) => $q->where('added_activity', false)->orWhereNull('added_activity'))
|
|
||||||
->orderByDesc('assigned_at');
|
|
||||||
|
|
||||||
$processedQuery = (clone $baseQuery)
|
|
||||||
->where('added_activity', true)
|
|
||||||
->orderByDesc('assigned_at');
|
|
||||||
|
|
||||||
|
// Get unique clients for filter dropdown
|
||||||
$clients = \App\Models\Client::query()
|
$clients = \App\Models\Client::query()
|
||||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
|
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
|
||||||
$q->where('assigned_user_id', $userId)
|
$q->where('assigned_user_id', $userId)
|
||||||
|
|
@ -80,8 +77,7 @@ public function index(Request $request): \Inertia\Response
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Phone/Index', [
|
return Inertia::render('Phone/Index', [
|
||||||
'pendingJobs' => Inertia::scroll(fn () => $pendingQuery->paginate(15, pageName: 'pending')),
|
'jobs' => $jobs,
|
||||||
'processedJobs' => Inertia::scroll(fn () => $processedQuery->paginate(15, pageName: 'processed')),
|
|
||||||
'clients' => $clients,
|
'clients' => $clients,
|
||||||
'view_mode' => 'assigned',
|
'view_mode' => 'assigned',
|
||||||
'filters' => [
|
'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;
|
$userId = $request->user()->id;
|
||||||
$search = $request->input('search');
|
$search = $request->input('search');
|
||||||
$clientFilter = $request->input('client');
|
$clientFilter = $request->input('client');
|
||||||
|
$perPage = $request->integer('per_page', 15);
|
||||||
|
$perPage = max(1, min(100, $perPage));
|
||||||
|
|
||||||
$start = now()->startOfDay();
|
$start = now()->startOfDay();
|
||||||
$end = now()->endOfDay();
|
$end = now()->endOfDay();
|
||||||
|
|
@ -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()
|
$clients = \App\Models\Client::query()
|
||||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
|
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
|
||||||
$q->where('assigned_user_id', $userId)
|
$q->where('assigned_user_id', $userId)
|
||||||
|
|
@ -156,7 +157,7 @@ public function completedToday(Request $request): \Inertia\Response
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Phone/Index', [
|
return Inertia::render('Phone/Index', [
|
||||||
'completedJobs' => Inertia::scroll(fn () => $query->paginate(15, pageName: 'completed')),
|
'jobs' => $jobs,
|
||||||
'clients' => $clients,
|
'clients' => $clients,
|
||||||
'view_mode' => 'completed-today',
|
'view_mode' => 'completed-today',
|
||||||
'filters' => [
|
'filters' => [
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
use App\Models\Decision;
|
use App\Models\Decision;
|
||||||
use App\Models\EmailTemplate;
|
use App\Models\EmailTemplate;
|
||||||
use App\Models\Segment;
|
use App\Models\Segment;
|
||||||
use App\Services\DecisionEvents\ConditionEvaluator;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
@ -23,8 +22,6 @@ public function index(Request $request)
|
||||||
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
|
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
|
||||||
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
|
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
|
||||||
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
|
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
|
||||||
'condition_fields' => ConditionEvaluator::availableFields(),
|
|
||||||
'condition_operators' => ConditionEvaluator::availableOperators(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,9 +83,6 @@ public function updateAction(int $id, Request $request)
|
||||||
|
|
||||||
public function storeDecision(Request $request)
|
public function storeDecision(Request $request)
|
||||||
{
|
{
|
||||||
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
|
||||||
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
|
||||||
|
|
||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'color_tag' => 'nullable|string|max:25',
|
'color_tag' => 'nullable|string|max:25',
|
||||||
|
|
@ -102,14 +96,6 @@ public function storeDecision(Request $request)
|
||||||
'events.*.active' => 'sometimes|boolean',
|
'events.*.active' => 'sometimes|boolean',
|
||||||
'events.*.run_order' => 'nullable|integer',
|
'events.*.run_order' => 'nullable|integer',
|
||||||
'events.*.config' => 'nullable|array',
|
'events.*.config' => 'nullable|array',
|
||||||
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
|
|
||||||
'events.*.config.deactivate_previous' => 'sometimes|boolean',
|
|
||||||
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
|
|
||||||
'events.*.config.reactivate' => 'sometimes|boolean',
|
|
||||||
'events.*.config.conditions' => 'nullable|array',
|
|
||||||
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
|
|
||||||
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
|
|
||||||
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||||
|
|
@ -126,12 +112,12 @@ public function storeDecision(Request $request)
|
||||||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||||
if ($key === 'add_segment') {
|
if ($key === 'add_segment') {
|
||||||
$seg = $ev['config']['segment_id'] ?? null;
|
$seg = $ev['config']['segment_id'] ?? null;
|
||||||
if (empty($seg)) {
|
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
||||||
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||||
}
|
}
|
||||||
} elseif ($key === 'archive_contract') {
|
} elseif ($key === 'archive_contract') {
|
||||||
$as = $ev['config']['archive_setting_id'] ?? null;
|
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||||
if (empty($as)) {
|
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.';
|
$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);
|
$row = Decision::findOrFail($id);
|
||||||
|
|
||||||
$allowedConditionFields = collect(ConditionEvaluator::availableFields())->pluck('key')->implode(',');
|
|
||||||
$allowedOperators = 'in:=,!=,>,>=,<,<=,contains';
|
|
||||||
|
|
||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'color_tag' => 'nullable|string|max:25',
|
'color_tag' => 'nullable|string|max:25',
|
||||||
|
|
@ -204,14 +187,6 @@ public function updateDecision(int $id, Request $request)
|
||||||
'events.*.active' => 'sometimes|boolean',
|
'events.*.active' => 'sometimes|boolean',
|
||||||
'events.*.run_order' => 'nullable|integer',
|
'events.*.run_order' => 'nullable|integer',
|
||||||
'events.*.config' => 'nullable|array',
|
'events.*.config' => 'nullable|array',
|
||||||
'events.*.config.segment_id' => 'nullable|integer|exists:segments,id',
|
|
||||||
'events.*.config.deactivate_previous' => 'sometimes|boolean',
|
|
||||||
'events.*.config.archive_setting_id' => 'nullable|integer|exists:archive_settings,id',
|
|
||||||
'events.*.config.reactivate' => 'sometimes|boolean',
|
|
||||||
'events.*.config.conditions' => 'nullable|array',
|
|
||||||
'events.*.config.conditions.*.field' => "required_with:events.*.config.conditions.*|string|in:{$allowedConditionFields}",
|
|
||||||
'events.*.config.conditions.*.operator' => "required_with:events.*.config.conditions.*|string|{$allowedOperators}",
|
|
||||||
'events.*.config.conditions.*.value' => 'required_with:events.*.config.conditions.*',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||||
|
|
@ -228,12 +203,12 @@ public function updateDecision(int $id, Request $request)
|
||||||
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||||
if ($key === 'add_segment') {
|
if ($key === 'add_segment') {
|
||||||
$seg = $ev['config']['segment_id'] ?? null;
|
$seg = $ev['config']['segment_id'] ?? null;
|
||||||
if (empty($seg)) {
|
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
||||||
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||||
}
|
}
|
||||||
} elseif ($key === 'archive_contract') {
|
} elseif ($key === 'archive_contract') {
|
||||||
$as = $ev['config']['archive_setting_id'] ?? null;
|
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||||
if (empty($as)) {
|
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.';
|
$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'),
|
'info' => fn () => $request->session()->get('info'),
|
||||||
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
||||||
],
|
],
|
||||||
'callLaterCount' => function () use ($request) {
|
|
||||||
if (! $request->user()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return \App\Models\CallLater::query()
|
|
||||||
->whereNull('completed_at')
|
|
||||||
->count();
|
|
||||||
},
|
|
||||||
'notifications' => function () use ($request) {
|
'notifications' => function () use ($request) {
|
||||||
try {
|
try {
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
|
||||||
|
|
@ -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\Activity;
|
||||||
use App\Models\Event as DecisionEventModel;
|
use App\Models\Event as DecisionEventModel;
|
||||||
use App\Services\DecisionEvents\ConditionEvaluator;
|
|
||||||
use App\Services\DecisionEvents\DecisionEventContext;
|
use App\Services\DecisionEvents\DecisionEventContext;
|
||||||
use App\Services\DecisionEvents\Registry;
|
use App\Services\DecisionEvents\Registry;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
|
|
@ -69,23 +68,6 @@ public function handle(): void
|
||||||
user: $activity->user,
|
user: $activity->user,
|
||||||
);
|
);
|
||||||
|
|
||||||
// [2] Condition check — skip the event if any condition is not met
|
|
||||||
$conditions = $this->config['conditions'] ?? [];
|
|
||||||
if (! empty($conditions)) {
|
|
||||||
$conditionsMet = app(ConditionEvaluator::class)->evaluate($conditions, $context);
|
|
||||||
if (! $conditionsMet) {
|
|
||||||
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
|
||||||
'status' => 'skipped',
|
|
||||||
'message' => 'Condition not met',
|
|
||||||
'finished_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// [3] Resolve handler → handle()
|
|
||||||
$handler->handle($context, $this->config);
|
$handler->handle($context, $this->config);
|
||||||
|
|
||||||
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,9 @@
|
||||||
|
|
||||||
class Account extends Model
|
class Account extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'reference',
|
'reference',
|
||||||
|
|
@ -59,11 +58,6 @@ public function payments(): HasMany
|
||||||
return $this->hasMany(\App\Models\Payment::class);
|
return $this->hasMany(\App\Models\Payment::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function installments(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(\App\Models\Installment::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function bookings(): HasMany
|
public function bookings(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\Booking::class);
|
return $this->hasMany(\App\Models\Booking::class);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ class Activity extends Model
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'due_date',
|
'due_date',
|
||||||
'call_back_at',
|
|
||||||
'amount',
|
'amount',
|
||||||
'note',
|
'note',
|
||||||
'action_id',
|
'action_id',
|
||||||
|
|
@ -28,13 +27,6 @@ class Activity extends Model
|
||||||
'client_case_id',
|
'client_case_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
/*protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'call_back_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}*/
|
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'action_id',
|
'action_id',
|
||||||
'decision_id',
|
'decision_id',
|
||||||
|
|
@ -154,9 +146,4 @@ public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\User::class);
|
return $this->belongsTo(\App\Models\User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(\App\Models\CallLater::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
'active',
|
'active',
|
||||||
'login_redirect',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
use App\Actions\Fortify\ResetUserPassword;
|
use App\Actions\Fortify\ResetUserPassword;
|
||||||
use App\Actions\Fortify\UpdateUserPassword;
|
use App\Actions\Fortify\UpdateUserPassword;
|
||||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||||
use App\Http\Responses\LoginResponse;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -15,7 +14,6 @@
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
|
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
class FortifyServiceProvider extends ServiceProvider
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
|
|
@ -25,7 +23,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function register(): void
|
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
|
// Resolve eligible recipients: client's person emails with receive_auto_mails = true
|
||||||
$recipients = [];
|
$recipients = [];
|
||||||
if ($client && $client->person) {
|
if ($client && $client->person) {
|
||||||
$emails = Email::query()
|
$recipients = Email::query()
|
||||||
->where('person_id', $client->person->id)
|
->where('person_id', $client->person->id)
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
->where('receive_auto_mails', true)
|
->where('receive_auto_mails', true)
|
||||||
->get(['value', 'preferences']);
|
|
||||||
|
|
||||||
$recipients = $emails
|
|
||||||
->filter(function (Email $email) use ($decision): bool {
|
|
||||||
$decisionIds = $email->preferences['decision_ids'] ?? [];
|
|
||||||
|
|
||||||
// Empty list means "all decisions" — always receive
|
|
||||||
if (empty($decisionIds)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return in_array((int) $decision->id, array_map('intval', $decisionIds), true);
|
|
||||||
})
|
|
||||||
->pluck('value')
|
->pluck('value')
|
||||||
->map(fn ($v) => strtolower(trim((string) $v)))
|
->map(fn ($v) => strtolower(trim((string) $v)))
|
||||||
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
|
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@
|
||||||
class ClientCaseDataService
|
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()
|
$query = $clientCase->contracts()
|
||||||
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
|
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
|
||||||
|
|
@ -40,8 +40,9 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Co
|
||||||
$query->forSegment($segmentId);
|
$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'];
|
$setting->reactivate = (bool) $config['reactivate'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel all active FieldJobs for this contract before archiving (raw update to avoid boot-event side effects)
|
|
||||||
\DB::table('field_jobs')
|
|
||||||
->where('contract_id', $contractId)
|
|
||||||
->whereNull('completed_at')
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->update(['cancelled_at' => now(), 'updated_at' => now()]);
|
|
||||||
|
|
||||||
$results = app(ArchiveExecutor::class)->executeSetting(
|
$results = app(ArchiveExecutor::class)->executeSetting(
|
||||||
$setting,
|
$setting,
|
||||||
['contract_id' => $contractId],
|
['contract_id' => $contractId],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
'add_segment' => AddSegmentHandler::class,
|
||||||
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class,
|
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class,
|
||||||
'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class,
|
'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class,
|
||||||
'add_call_later' => \App\Services\DecisionEvents\Handlers\CallLaterHandler::class,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function resolve(string $key): DecisionEventHandler
|
public static function resolve(string $key): DecisionEventHandler
|
||||||
{
|
{
|
||||||
$key = trim(strtolower($key));
|
$key = trim(strtolower($key));
|
||||||
$class = static::$map[$key] ?? null;
|
$class = static::$map[$key] ?? null;
|
||||||
if (! $class) {
|
if (! $class || ! class_exists($class)) {
|
||||||
throw new InvalidArgumentException("Unknown decision event handler for key: {$key}");
|
throw new InvalidArgumentException("Unknown decision event handler for key: {$key}");
|
||||||
}
|
}
|
||||||
if (! class_exists($class)) {
|
|
||||||
throw new InvalidArgumentException("Handler class {$class} for key {$key} does not exist (check autoload)");
|
|
||||||
}
|
|
||||||
$handler = app($class);
|
$handler = app($class);
|
||||||
if (! $handler instanceof DecisionEventHandler) {
|
if (! $handler instanceof DecisionEventHandler) {
|
||||||
throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler");
|
throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler");
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,21 @@
|
||||||
"barryvdh/laravel-dompdf": "^3.1",
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
"diglactic/laravel-breadcrumbs": "^10.0",
|
"diglactic/laravel-breadcrumbs": "^10.0",
|
||||||
"http-interop/http-factory-guzzle": "^1.2",
|
"http-interop/http-factory-guzzle": "^1.2",
|
||||||
"inertiajs/inertia-laravel": "^3.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "12.0",
|
||||||
"laravel/jetstream": "^5.2",
|
"laravel/jetstream": "^5.2",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/scout": "^10.11",
|
"laravel/scout": "^10.11",
|
||||||
"laravel/tinker": "^2.9",
|
"laravel/tinker": "^2.9",
|
||||||
"maatwebsite/excel": "^3.1",
|
"maatwebsite/excel": "^3.1",
|
||||||
"meilisearch/meilisearch-php": "^1.11",
|
"meilisearch/meilisearch-php": "^1.11",
|
||||||
"robertboes/inertia-breadcrumbs": "^1.0",
|
"robertboes/inertia-breadcrumbs": "dev-laravel-12",
|
||||||
"tightenco/ziggy": "^2.0",
|
"tightenco/ziggy": "^2.0",
|
||||||
"tijsverkoyen/css-to-inline-styles": "^2.2"
|
"tijsverkoyen/css-to-inline-styles": "^2.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"laravel/boost": "^2.2",
|
"laravel/boost": "^1.1",
|
||||||
"laravel/pint": "^1.13",
|
"laravel/pint": "^1.13",
|
||||||
"laravel/sail": "^1.26",
|
"laravel/sail": "^1.26",
|
||||||
"mockery/mockery": "^1.6",
|
"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',
|
'name' => 'End field job',
|
||||||
'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).',
|
'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).',
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'key' => 'add_call_later',
|
|
||||||
'name' => 'Klic kasneje',
|
|
||||||
'description' => 'Ustvari zapis za povratni klic ob določenem datumu in uri.',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
|
|
||||||
180
package-lock.json
generated
180
package-lock.json
generated
|
|
@ -46,7 +46,7 @@
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inertiajs/vue3": "^3.0",
|
"@inertiajs/vue3": "2.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
|
@ -952,35 +952,26 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@inertiajs/core": {
|
"node_modules/@inertiajs/core": {
|
||||||
"version": "3.0.3",
|
"version": "2.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.0.17.tgz",
|
||||||
"integrity": "sha512-/4sW/cfNpvujjVOZlB5UNypLGNySs7X7V8IMLNSK8+3j1KsUYGS5wpLd9EqAu8wy8RiW7PPra2rPwB6Lx/ACow==",
|
"integrity": "sha512-tvYoqiouQSJrP7i7zVq61yyuEjlL96UU4nkkOWtOajXZlubGN4XrgRpnygpDk1KBO8V2yBab3oUZm+aZImwTHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/trace-mapping": "^0.3.31",
|
"axios": "^1.8.2",
|
||||||
"es-toolkit": "^1.33.0",
|
"es-toolkit": "^1.34.1",
|
||||||
"laravel-precognition": "^2.0.0"
|
"qs": "^6.9.0"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"axios": "^1.13.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"axios": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@inertiajs/vue3": {
|
"node_modules/@inertiajs/vue3": {
|
||||||
"version": "3.0.3",
|
"version": "2.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.0.17.tgz",
|
||||||
"integrity": "sha512-bhJN+GS66g1tYH1p6flKkG1N8oaT5J7ZLqBkavN9mHC6bVfoQCUG6sCuA07WTDfo9tDaxU89wsSSAf4mhn3SuA==",
|
"integrity": "sha512-Al0IMHQSj5aTQBLUAkljFEMCw4YRwSiOSKzN8LAbvJpKwvJFgc/wSj3wVVpr/AO9y9mz1w2mtvjnDoOzsntPLw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inertiajs/core": "3.0.3",
|
"@inertiajs/core": "2.0.17",
|
||||||
"es-toolkit": "^1.33.0",
|
"es-toolkit": "^1.33.0"
|
||||||
"laravel-precognition": "^2.0.0"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.0.0"
|
"vue": "^3.0.0"
|
||||||
|
|
@ -3813,9 +3804,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-toolkit": {
|
"node_modules/es-toolkit": {
|
||||||
"version": "1.45.1",
|
"version": "1.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|
@ -4381,24 +4372,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/laravel-precognition": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-toolkit": "^1.32.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"axios": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"axios": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/laravel-vite-plugin": {
|
"node_modules/laravel-vite-plugin": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
|
||||||
|
|
@ -4902,6 +4875,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-is": {
|
"node_modules/object-is": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||||
|
|
@ -5112,6 +5098,22 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
|
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/quickselect": {
|
"node_modules/quickselect": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||||
|
|
@ -5359,6 +5361,82 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-list": "^1.0.0",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/skema": {
|
"node_modules/skema": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz",
|
||||||
|
|
@ -5951,6 +6029,24 @@
|
||||||
"which": "bin/which"
|
"which": "bin/which"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inertiajs/vue3": "^3.0",
|
"@inertiajs/vue3": "2.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@
|
||||||
<testsuite name="Feature">
|
<testsuite name="Feature">
|
||||||
<directory>tests/Feature</directory>
|
<directory>tests/Feature</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
<testsuite name="Pure">
|
|
||||||
<directory>tests/Pure</directory>
|
|
||||||
</testsuite>
|
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<source>
|
<source>
|
||||||
<include>
|
<include>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
} from "@tanstack/vue-table";
|
} from "@tanstack/vue-table";
|
||||||
import { valueUpdater } from "@/lib/utils";
|
import { valueUpdater } from "@/lib/utils";
|
||||||
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
|
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
|
||||||
import DataTablePaginationClient from "./DataTablePaginationClient.vue";
|
import DataTablePagination from "./DataTablePagination.vue";
|
||||||
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
||||||
import DataTableToolbar from "./DataTableToolbar.vue";
|
import DataTableToolbar from "./DataTableToolbar.vue";
|
||||||
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
|
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
|
||||||
|
|
@ -618,14 +618,7 @@ defineExpose({
|
||||||
|
|
||||||
<!-- Client-side pagination -->
|
<!-- Client-side pagination -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<DataTablePaginationClient
|
<DataTablePagination :table="table" />
|
||||||
:current-page="table.getState().pagination.pageIndex"
|
|
||||||
:last-page="table.getPageCount()"
|
|
||||||
:total="table.getFilteredRowModel().rows.length"
|
|
||||||
:showing-from="table.getFilteredSelectedRowModel().rows.length"
|
|
||||||
:showing-to="table.getFilteredRowModel().rows.length"
|
|
||||||
:table="table"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ const props = defineProps({
|
||||||
showGoto: { type: Boolean, default: true },
|
showGoto: { type: Boolean, default: true },
|
||||||
maxPageLinks: { type: Number, default: 5 },
|
maxPageLinks: { type: Number, default: 5 },
|
||||||
perPage: { type: Number, default: 10 },
|
perPage: { type: Number, default: 10 },
|
||||||
table: { type: Object, required: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:page"]);
|
const emit = defineEmits(["update:page"]);
|
||||||
|
|
@ -35,7 +34,7 @@ function goToPageInput() {
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
if (!Number.isFinite(n)) return;
|
if (!Number.isFinite(n)) return;
|
||||||
const target = Math.max(1, Math.min(props.lastPage, Math.floor(n)));
|
const target = Math.max(1, Math.min(props.lastPage, Math.floor(n)));
|
||||||
if (target !== props.currentPage) props.table.setPageIndex(target - 1);
|
if (target !== props.currentPage) setPage(target);
|
||||||
gotoInput.value = "";
|
gotoInput.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,17 +136,14 @@ function setPage(p) {
|
||||||
>
|
>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<!-- First -->
|
<!-- First -->
|
||||||
<PaginationFirst
|
<PaginationFirst :disabled="currentPage <= 1" @click="setPage(1)">
|
||||||
:disabled="!table.getCanPreviousPage()"
|
|
||||||
@click="table.setPageIndex(0)"
|
|
||||||
>
|
|
||||||
<ChevronsLeft />
|
<ChevronsLeft />
|
||||||
</PaginationFirst>
|
</PaginationFirst>
|
||||||
|
|
||||||
<!-- Previous -->
|
<!-- Previous -->
|
||||||
<PaginationPrevious
|
<PaginationPrevious
|
||||||
:disabled="!table.getCanPreviousPage()"
|
:disabled="currentPage <= 1"
|
||||||
@click="table.previousPage()"
|
@click="setPage(currentPage - 1)"
|
||||||
>
|
>
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
</PaginationPrevious>
|
</PaginationPrevious>
|
||||||
|
|
@ -158,22 +154,25 @@ function setPage(p) {
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
v-else
|
v-else
|
||||||
:value="item"
|
:value="item"
|
||||||
:is-active="currentPage === index"
|
:is-active="currentPage === item"
|
||||||
@click="table.setPageIndex(index)"
|
@click="setPage(item)"
|
||||||
>
|
>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Next -->
|
<!-- Next -->
|
||||||
<PaginationNext :disabled="!table.getCanNextPage()" @click="table.nextPage()">
|
<PaginationNext
|
||||||
|
:disabled="currentPage >= lastPage"
|
||||||
|
@click="setPage(currentPage + 1)"
|
||||||
|
>
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</PaginationNext>
|
</PaginationNext>
|
||||||
|
|
||||||
<!-- Last -->
|
<!-- Last -->
|
||||||
<PaginationLast
|
<PaginationLast
|
||||||
:disabled="!table.getCanNextPage()"
|
:disabled="currentPage >= lastPage"
|
||||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
@click="setPage(lastPage)"
|
||||||
>
|
>
|
||||||
<ChevronsRight />
|
<ChevronsRight />
|
||||||
</PaginationLast>
|
</PaginationLast>
|
||||||
|
|
@ -192,7 +191,7 @@ function setPage(p) {
|
||||||
:max="lastPage"
|
:max="lastPage"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
:placeholder="String(currentPage + 1)"
|
:placeholder="String(currentPage)"
|
||||||
aria-label="Pojdi na stran"
|
aria-label="Pojdi na stran"
|
||||||
@keyup.enter="goToPageInput"
|
@keyup.enter="goToPageInput"
|
||||||
@blur="goToPageInput"
|
@blur="goToPageInput"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,11 @@
|
||||||
import { computed, ref, useAttrs } from "vue";
|
import { computed, ref, useAttrs } from "vue";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Calendar } from "@/Components/ui/calendar";
|
import { Calendar } from "@/Components/ui/calendar";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/Components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CalendarIcon } from "lucide-vue-next";
|
import { CalendarIcon } from "lucide-vue-next";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
@ -82,9 +86,7 @@ const toCalendarDate = (value) => {
|
||||||
// Convert CalendarDate to ISO string (YYYY-MM-DD)
|
// Convert CalendarDate to ISO string (YYYY-MM-DD)
|
||||||
const fromCalendarDate = (calendarDate) => {
|
const fromCalendarDate = (calendarDate) => {
|
||||||
if (!calendarDate) return null;
|
if (!calendarDate) return null;
|
||||||
return `${String(calendarDate.year).padStart(4, "0")}-${String(
|
return `${String(calendarDate.year).padStart(4, "0")}-${String(calendarDate.month).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
|
||||||
calendarDate.month
|
|
||||||
).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendarDate = computed({
|
const calendarDate = computed({
|
||||||
|
|
@ -140,10 +142,11 @@ const open = ref(false);
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-auto p-0" align="start">
|
<PopoverContent class="w-auto p-0" align="start">
|
||||||
<Calendar locale="si-SI" v-model="calendarDate" :disabled="disabled" />
|
<Calendar v-model="calendarDate" :disabled="disabled" />
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<p v-if="error" class="mt-1 text-sm text-red-600">
|
<p v-if="error" class="mt-1 text-sm text-red-600">
|
||||||
{{ Array.isArray(error) ? error[0] : error }}
|
{{ Array.isArray(error) ? error[0] : error }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onUnmounted } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
} from "@/Components/ui/dialog";
|
} from "@/Components/ui/dialog";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Badge } from "../ui/badge";
|
import { Badge } from "../ui/badge";
|
||||||
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
|
import { Loader2 } from "lucide-vue-next";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -26,141 +26,6 @@ const loading = ref(false);
|
||||||
const previewGenerating = ref(false);
|
const previewGenerating = ref(false);
|
||||||
const previewError = ref("");
|
const previewError = ref("");
|
||||||
|
|
||||||
// Image viewer – zoom & pan state
|
|
||||||
const containerRef = ref(null);
|
|
||||||
const imageRef = ref(null);
|
|
||||||
const imageScale = ref(1);
|
|
||||||
const translateX = ref(0);
|
|
||||||
const translateY = ref(0);
|
|
||||||
const fitScale = ref(1);
|
|
||||||
const isDragging = ref(false);
|
|
||||||
const hasMoved = ref(false);
|
|
||||||
const dragStartX = ref(0);
|
|
||||||
const dragStartY = ref(0);
|
|
||||||
const dragStartTX = ref(0);
|
|
||||||
const dragStartTY = ref(0);
|
|
||||||
|
|
||||||
const MAX_SCALE = 8;
|
|
||||||
|
|
||||||
const imageCursorClass = computed(() => {
|
|
||||||
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
|
|
||||||
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
|
|
||||||
return "cursor-default";
|
|
||||||
});
|
|
||||||
|
|
||||||
const initImageView = () => {
|
|
||||||
const container = containerRef.value;
|
|
||||||
const img = imageRef.value;
|
|
||||||
if (!container || !img) return;
|
|
||||||
const cW = container.clientWidth;
|
|
||||||
const cH = container.clientHeight;
|
|
||||||
const iW = img.naturalWidth || cW;
|
|
||||||
const iH = img.naturalHeight || cH;
|
|
||||||
const fs = Math.min(1, cW / iW, cH / iH);
|
|
||||||
fitScale.value = fs;
|
|
||||||
imageScale.value = fs;
|
|
||||||
translateX.value = (cW - iW * fs) / 2;
|
|
||||||
translateY.value = (cH - iH * fs) / 2;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetImageView = () => {
|
|
||||||
initImageView();
|
|
||||||
};
|
|
||||||
|
|
||||||
const clampTranslate = (tx, ty, scale) => {
|
|
||||||
const container = containerRef.value;
|
|
||||||
const img = imageRef.value;
|
|
||||||
if (!container || !img) return { tx, ty };
|
|
||||||
const cW = container.clientWidth;
|
|
||||||
const cH = container.clientHeight;
|
|
||||||
const iW = img.naturalWidth * scale;
|
|
||||||
const iH = img.naturalHeight * scale;
|
|
||||||
// When image fills the container: clamp so image edges stay within container.
|
|
||||||
// When image is smaller than container: keep it centered.
|
|
||||||
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
|
|
||||||
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
|
|
||||||
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
|
|
||||||
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
|
|
||||||
return {
|
|
||||||
tx: Math.min(maxX, Math.max(minX, tx)),
|
|
||||||
ty: Math.min(maxY, Math.max(minY, ty)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomAt = (mx, my, factor) => {
|
|
||||||
const img = imageRef.value;
|
|
||||||
const iW = img?.naturalWidth ?? 1;
|
|
||||||
const iH = img?.naturalHeight ?? 1;
|
|
||||||
const raw = imageScale.value * factor;
|
|
||||||
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
|
|
||||||
if (newScale === imageScale.value) return;
|
|
||||||
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
|
|
||||||
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
|
|
||||||
const clamped = clampTranslate(tx, ty, newScale);
|
|
||||||
translateX.value = clamped.tx;
|
|
||||||
translateY.value = clamped.ty;
|
|
||||||
imageScale.value = newScale;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mousePos = (e) => {
|
|
||||||
const rect = containerRef.value.getBoundingClientRect();
|
|
||||||
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageLoad = () => {
|
|
||||||
initImageView();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWheel = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const { mx, my } = mousePos(e);
|
|
||||||
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseMove = (e) => {
|
|
||||||
if (!isDragging.value) return;
|
|
||||||
const dx = e.clientX - dragStartX.value;
|
|
||||||
const dy = e.clientY - dragStartY.value;
|
|
||||||
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
|
||||||
hasMoved.value = true;
|
|
||||||
}
|
|
||||||
if (hasMoved.value) {
|
|
||||||
const clamped = clampTranslate(
|
|
||||||
dragStartTX.value + dx,
|
|
||||||
dragStartTY.value + dy,
|
|
||||||
imageScale.value
|
|
||||||
);
|
|
||||||
translateX.value = clamped.tx;
|
|
||||||
translateY.value = clamped.ty;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseUp = () => {
|
|
||||||
isDragging.value = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
hasMoved.value = false;
|
|
||||||
}, 0);
|
|
||||||
window.removeEventListener("mousemove", onMouseMove);
|
|
||||||
window.removeEventListener("mouseup", onMouseUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseDown = (e) => {
|
|
||||||
if (e.button !== 0) return;
|
|
||||||
isDragging.value = true;
|
|
||||||
hasMoved.value = false;
|
|
||||||
dragStartX.value = e.clientX;
|
|
||||||
dragStartY.value = e.clientY;
|
|
||||||
dragStartTX.value = translateX.value;
|
|
||||||
dragStartTY.value = translateY.value;
|
|
||||||
window.addEventListener("mousemove", onMouseMove);
|
|
||||||
window.addEventListener("mouseup", onMouseUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener("mousemove", onMouseMove);
|
|
||||||
window.removeEventListener("mouseup", onMouseUp);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileExtension = computed(() => {
|
const fileExtension = computed(() => {
|
||||||
if (props.filename) {
|
if (props.filename) {
|
||||||
return props.filename.split(".").pop()?.toLowerCase() || "";
|
return props.filename.split(".").pop()?.toLowerCase() || "";
|
||||||
|
|
@ -253,10 +118,6 @@ watch(
|
||||||
previewGenerating.value = false;
|
previewGenerating.value = false;
|
||||||
previewError.value = "";
|
previewError.value = "";
|
||||||
docxPreviewUrl.value = "";
|
docxPreviewUrl.value = "";
|
||||||
imageScale.value = 1;
|
|
||||||
translateX.value = 0;
|
|
||||||
translateY.value = 0;
|
|
||||||
fitScale.value = 1;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
|
|
@ -318,51 +179,11 @@ watch(
|
||||||
|
|
||||||
<!-- Image Viewer -->
|
<!-- Image Viewer -->
|
||||||
<template v-else-if="viewerType === 'image' && props.src">
|
<template v-else-if="viewerType === 'image' && props.src">
|
||||||
<div
|
|
||||||
ref="containerRef"
|
|
||||||
class="relative h-full overflow-hidden select-none"
|
|
||||||
:class="imageCursorClass"
|
|
||||||
@mousedown="handleMouseDown"
|
|
||||||
@wheel.prevent="handleWheel"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
ref="imageRef"
|
|
||||||
:src="props.src"
|
:src="props.src"
|
||||||
:alt="props.title"
|
:alt="props.title"
|
||||||
draggable="false"
|
class="max-w-full max-h-full mx-auto object-contain"
|
||||||
class="absolute top-0 left-0 max-w-none"
|
|
||||||
:style="{
|
|
||||||
transformOrigin: '0 0',
|
|
||||||
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
|
|
||||||
transition: isDragging ? 'none' : 'transform 0.12s ease',
|
|
||||||
}"
|
|
||||||
@load="handleImageLoad"
|
|
||||||
/>
|
/>
|
||||||
<!-- Zoom level badge -->
|
|
||||||
<div
|
|
||||||
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
|
|
||||||
>
|
|
||||||
{{ Math.round(imageScale * 100) }}%
|
|
||||||
</div>
|
|
||||||
<!-- Reset button -->
|
|
||||||
<Button
|
|
||||||
v-if="imageScale > fitScale + 0.01"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="secondary"
|
|
||||||
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
|
|
||||||
title="Ponastavi pogled"
|
|
||||||
@click.stop="resetImageView"
|
|
||||||
>
|
|
||||||
<RotateCcwIcon class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<!-- Hint -->
|
|
||||||
<div
|
|
||||||
v-if="imageScale <= fitScale + 0.01"
|
|
||||||
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
|
|
||||||
>
|
|
||||||
Kolesce za povečavo / pomanjšavo · Povleči za premik
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Text/CSV/XML Viewer -->
|
<!-- Text/CSV/XML Viewer -->
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@ import { computed, ref, watch } from "vue";
|
||||||
import { useForm, Field as FormField } from "vee-validate";
|
import { useForm, Field as FormField } from "vee-validate";
|
||||||
import { toTypedSchema } from "@vee-validate/zod";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { router, usePage } from "@inertiajs/vue3";
|
import { router } from "@inertiajs/vue3";
|
||||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
|
||||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||||
import SectionTitle from "../SectionTitle.vue";
|
import SectionTitle from "../SectionTitle.vue";
|
||||||
|
|
@ -28,22 +27,12 @@ const props = defineProps({
|
||||||
|
|
||||||
const emit = defineEmits(["close"]);
|
const emit = defineEmits(["close"]);
|
||||||
|
|
||||||
// Decisions with auto_mail = true from shared Inertia data
|
|
||||||
const page = usePage();
|
|
||||||
const decisionOptions = computed(() =>
|
|
||||||
(page.props.auto_mail_decisions ?? []).map((d) => ({
|
|
||||||
value: String(d.id),
|
|
||||||
label: d.name,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Zod schema for form validation
|
// Zod schema for form validation
|
||||||
const formSchema = toTypedSchema(
|
const formSchema = toTypedSchema(
|
||||||
z.object({
|
z.object({
|
||||||
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
|
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
receive_auto_mails: z.boolean().optional(),
|
receive_auto_mails: z.boolean().optional(),
|
||||||
decision_ids: z.array(z.string()).optional().default([]),
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -54,13 +43,9 @@ const form = useForm({
|
||||||
value: "",
|
value: "",
|
||||||
label: "",
|
label: "",
|
||||||
receive_auto_mails: false,
|
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 processing = ref(false);
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
|
|
@ -72,44 +57,22 @@ const close = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
limitToDecisions.value = false;
|
|
||||||
form.resetForm({
|
form.resetForm({
|
||||||
values: {
|
values: {
|
||||||
value: "",
|
value: "",
|
||||||
label: "",
|
label: "",
|
||||||
receive_auto_mails: false,
|
receive_auto_mails: false,
|
||||||
decision_ids: [],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// When auto mails is disabled, collapse the decision filter
|
|
||||||
watch(
|
|
||||||
() => form.values.receive_auto_mails,
|
|
||||||
(val) => {
|
|
||||||
if (!val) {
|
|
||||||
limitToDecisions.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// When limit toggle is turned off, clear the selection
|
|
||||||
watch(limitToDecisions, (val) => {
|
|
||||||
if (!val) {
|
|
||||||
form.setFieldValue("decision_ids", []);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
const payload = {
|
const { values } = form;
|
||||||
...form.values,
|
|
||||||
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
|
|
||||||
};
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
route("person.email.create", props.person),
|
route("person.email.create", props.person),
|
||||||
payload,
|
values,
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -135,14 +98,11 @@ const create = async () => {
|
||||||
|
|
||||||
const update = async () => {
|
const update = async () => {
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
const payload = {
|
const { values } = form;
|
||||||
...form.values,
|
|
||||||
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
|
|
||||||
};
|
|
||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
route("person.email.update", { person: props.person, email_id: props.id }),
|
route("person.email.update", { person: props.person, email_id: props.id }),
|
||||||
payload,
|
values,
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -176,13 +136,10 @@ watch(
|
||||||
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
|
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
|
||||||
const email = list.find((e) => e.id === props.id);
|
const email = list.find((e) => e.id === props.id);
|
||||||
if (email) {
|
if (email) {
|
||||||
const existingDecisionIds = (email.preferences?.decision_ids ?? []).map(String);
|
|
||||||
limitToDecisions.value = existingDecisionIds.length > 0;
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
value: email.value ?? email.email ?? email.address ?? "",
|
value: email.value ?? email.email ?? email.address ?? "",
|
||||||
label: email.label ?? "",
|
label: email.label ?? "",
|
||||||
receive_auto_mails: !!email.receive_auto_mails,
|
receive_auto_mails: !!email.receive_auto_mails,
|
||||||
decision_ids: existingDecisionIds,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|
@ -271,36 +228,6 @@ const onConfirm = () => {
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<!-- Limit to specific decisions — only shown when receive_auto_mails is on and decisions exist -->
|
|
||||||
<template v-if="(props.person?.client || isClientContext) && form.values.receive_auto_mails && decisionOptions.length > 0">
|
|
||||||
<div class="flex flex-row items-start space-x-3 space-y-0">
|
|
||||||
<Switch
|
|
||||||
:model-value="limitToDecisions"
|
|
||||||
@update:model-value="(val) => (limitToDecisions = val)"
|
|
||||||
/>
|
|
||||||
<div class="space-y-1 leading-none">
|
|
||||||
<label class="text-sm font-medium leading-none cursor-pointer" @click="limitToDecisions = !limitToDecisions">
|
|
||||||
Omeji na posamezne odločitve
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField v-if="limitToDecisions" v-slot="{ value, handleChange }" name="decision_ids">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Odločitve, za katere se pošlje e-pošta</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<AppMultiSelect
|
|
||||||
:model-value="value ?? []"
|
|
||||||
:items="decisionOptions"
|
|
||||||
placeholder="Izberi odločitve..."
|
|
||||||
@update:model-value="handleChange"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import {
|
||||||
} from "@/Components/ui/select";
|
} from "@/Components/ui/select";
|
||||||
import { Switch } from "@/Components/ui/switch";
|
import { Switch } from "@/Components/ui/switch";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { ScrollArea } from "@/Components/ui/scroll-area";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: { type: Boolean, default: false },
|
show: { type: Boolean, default: false },
|
||||||
|
|
@ -453,8 +452,7 @@ const open = computed({
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ScrollArea class="max-h-[65vh] pr-1">
|
<form @submit.prevent="onSubmit" class="space-y-4">
|
||||||
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormField v-slot="{ value, handleChange }" name="profile_id">
|
<FormField v-slot="{ value, handleChange }" name="profile_id">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
|
@ -584,8 +582,8 @@ const open = computed({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[11px] text-gray-500 leading-snug">
|
<p class="text-[11px] text-gray-500 leading-snug">
|
||||||
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake,
|
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake, ki
|
||||||
ki ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
|
ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
|
||||||
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS‑2). V tem
|
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS‑2). V tem
|
||||||
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
|
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
|
||||||
sporočilih 67 znakov na del), medtem ko je pri GSM‑7 160 znakov (pri daljših
|
sporočilih 67 znakov na del), medtem ko je pri GSM‑7 160 znakov (pri daljših
|
||||||
|
|
@ -606,7 +604,6 @@ const open = computed({
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
</form>
|
</form>
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="closeSmsDialog" :disabled="processing">
|
<Button variant="outline" @click="closeSmsDialog" :disabled="processing">
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,8 @@ const summaryText = computed(() => {
|
||||||
const found = props.items.find((i) => String(i.value) === String(v));
|
const found = props.items.find((i) => String(i.value) === String(v));
|
||||||
return found?.label || v;
|
return found?.label || v;
|
||||||
});
|
});
|
||||||
if (labels.length <= 3) return labels.join(", ");
|
if (labels.length <= 3) return labels.join(', ');
|
||||||
const firstThree = labels.slice(0, 3).join(", ");
|
const firstThree = labels.slice(0, 3).join(', ');
|
||||||
const remaining = labels.length - 3;
|
const remaining = labels.length - 3;
|
||||||
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
|
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
|
||||||
});
|
});
|
||||||
|
|
@ -154,7 +154,7 @@ const summaryText = computed(() => {
|
||||||
:variant="chipVariant"
|
:variant="chipVariant"
|
||||||
class="flex items-center gap-1"
|
class="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span class="truncate max-w-35">
|
<span class="truncate max-w-[140px]">
|
||||||
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
|
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="cn('z-50 min-w-32 overflow-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 />
|
<slot />
|
||||||
</DropdownMenuContent>
|
</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,
|
InboxIcon,
|
||||||
AtSignIcon,
|
AtSignIcon,
|
||||||
BookUserIcon,
|
BookUserIcon,
|
||||||
|
MessageSquareIcon,
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import Dropdown from "@/Components/Dropdown.vue";
|
import Dropdown from "@/Components/Dropdown.vue";
|
||||||
|
|
@ -210,6 +211,13 @@ const navGroups = computed(() => [
|
||||||
icon: Settings2Icon,
|
icon: Settings2Icon,
|
||||||
active: ["admin.sms-profiles.index"],
|
active: ["admin.sms-profiles.index"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "admin.packages.index",
|
||||||
|
label: "SMS paketi",
|
||||||
|
route: "admin.packages.index",
|
||||||
|
icon: MessageSquareIcon,
|
||||||
|
active: ["admin.packages.index", "admin.packages.show"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,6 @@ import { SettingsIcon } from "lucide-vue-next";
|
||||||
import { ShieldUserIcon } from "lucide-vue-next";
|
import { ShieldUserIcon } from "lucide-vue-next";
|
||||||
import { SmartphoneIcon } from "lucide-vue-next";
|
import { SmartphoneIcon } from "lucide-vue-next";
|
||||||
import { TabletSmartphoneIcon } from "lucide-vue-next";
|
import { TabletSmartphoneIcon } from "lucide-vue-next";
|
||||||
import { PhoneCallIcon } from "lucide-vue-next";
|
|
||||||
import { PackageIcon } from "lucide-vue-next";
|
|
||||||
import { Badge } from "@/Components/ui/badge";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: String,
|
title: String,
|
||||||
|
|
@ -160,13 +157,6 @@ const rawMenuGroups = [
|
||||||
routeName: "segments.index",
|
routeName: "segments.index",
|
||||||
active: ["segments.index"],
|
active: ["segments.index"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "call-laters",
|
|
||||||
icon: PhoneCallIcon,
|
|
||||||
title: "Pokliči kasneje",
|
|
||||||
routeName: "callLaters.index",
|
|
||||||
active: ["callLaters.index"],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -222,13 +212,6 @@ const rawMenuGroups = [
|
||||||
routeName: "settings",
|
routeName: "settings",
|
||||||
active: ["settings", "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)
|
// Admin panel (roles & permissions management)
|
||||||
// Only shown if current user has admin role or manage-settings permission.
|
// Only shown if current user has admin role or manage-settings permission.
|
||||||
// We'll filter it out below if not authorized.
|
// We'll filter it out below if not authorized.
|
||||||
|
|
@ -285,14 +268,6 @@ function isActive(patterns) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBadge(item) {
|
|
||||||
if (item.key === "call-laters") {
|
|
||||||
return page.props.callLaterCount || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -366,18 +341,11 @@ function getBadge(item) {
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<span
|
<span
|
||||||
v-if="!sidebarCollapsed"
|
v-if="!sidebarCollapsed"
|
||||||
class="flex-1 truncate transition-opacity"
|
class="truncate transition-opacity"
|
||||||
:class="{ 'font-medium': isActive(item.active) }"
|
:class="{ 'font-medium': isActive(item.active) }"
|
||||||
>
|
>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
|
||||||
v-if="!sidebarCollapsed && getBadge(item) > 0"
|
|
||||||
variant="destructive"
|
|
||||||
class="ml-auto shrink-0 px-1.5 py-0.5 text-xs font-mono text-amber-50"
|
|
||||||
>
|
|
||||||
{{ getBadge(item) }}
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,7 @@ const closeSearch = () => (searchOpen.value = false);
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Page Heading -->
|
<!-- 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">
|
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
|
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,12 @@ const cards = [
|
||||||
route: "admin.sms-logs.index",
|
route: "admin.sms-logs.index",
|
||||||
icon: InboxIcon,
|
icon: InboxIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "SMS paketi",
|
||||||
|
description: "Kreiranje in pošiljanje serijskih SMS paketov",
|
||||||
|
route: "admin.packages.index",
|
||||||
|
icon: MessageSquareIcon,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
import { Link, router, useForm } from "@inertiajs/vue3";
|
import { Link, router, useForm } from "@inertiajs/vue3";
|
||||||
import { ref, computed, nextTick } from "vue";
|
import { ref, computed, nextTick } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
@ -112,9 +112,9 @@ function submitCreate() {
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
router.post(route("packages.store"), payload, {
|
router.post(route("admin.packages.store"), payload, {
|
||||||
onSuccess: () => {
|
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");
|
if (onlyValidated.value) params.append("only_validated", "1");
|
||||||
params.append("per_page", perPage.value);
|
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, {
|
const { data: json } = await axios.get(target, {
|
||||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||||
});
|
});
|
||||||
|
|
@ -268,7 +268,7 @@ function goToPage(page) {
|
||||||
params.append("per_page", perPage.value);
|
params.append("per_page", perPage.value);
|
||||||
params.append("page", page);
|
params.append("page", page);
|
||||||
|
|
||||||
const url = `${route("packages.contracts")}?${params.toString()}`;
|
const url = `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||||
loadContracts(url);
|
loadContracts(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,9 +312,9 @@ function submitCreateFromContracts() {
|
||||||
};
|
};
|
||||||
|
|
||||||
creatingFromContracts.value = true;
|
creatingFromContracts.value = true;
|
||||||
router.post(route("packages.store-from-contracts"), payload, {
|
router.post(route("admin.packages.store-from-contracts"), payload, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.visit(route("packages.index"));
|
router.visit(route("admin.packages.index"));
|
||||||
},
|
},
|
||||||
onError: (errors) => {
|
onError: (errors) => {
|
||||||
const first = errors && Object.values(errors)[0];
|
const first = errors && Object.values(errors)[0];
|
||||||
|
|
@ -337,11 +337,11 @@ const numbersCount = computed(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="Ustvari SMS paket">
|
<AdminLayout title="Ustvari SMS paket">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<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">
|
<Button variant="ghost" size="sm">
|
||||||
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||||
Nazaj
|
Nazaj
|
||||||
|
|
@ -520,7 +520,7 @@ const numbersCount = computed(() => {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
@click="router.visit(route('packages.index'))"
|
@click="router.visit(route('admin.packages.index'))"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
Prekliči
|
Prekliči
|
||||||
|
|
@ -703,7 +703,7 @@ const numbersCount = computed(() => {
|
||||||
Izbrano: {{ selectedContractIds.size }}
|
Izbrano: {{ selectedContractIds.size }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
@click="router.visit(route('packages.index'))"
|
@click="router.visit(route('admin.packages.index'))"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
Prekliči
|
Prekliči
|
||||||
|
|
@ -806,5 +806,5 @@ const numbersCount = computed(() => {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</AppLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
import { Link, router } from "@inertiajs/vue3";
|
import { Link, router } from "@inertiajs/vue3";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
|
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||||
|
|
@ -48,7 +48,7 @@ function getStatusVariant(status) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function goShow(id) {
|
function goShow(id) {
|
||||||
router.visit(route("packages.show", id));
|
router.visit(route("admin.packages.show", id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeleteDialog(pkg) {
|
function openDeleteDialog(pkg) {
|
||||||
|
|
@ -60,7 +60,7 @@ function openDeleteDialog(pkg) {
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
if (!packageToDelete.value) return;
|
if (!packageToDelete.value) return;
|
||||||
deletingId.value = packageToDelete.value.id;
|
deletingId.value = packageToDelete.value.id;
|
||||||
router.delete(route("packages.destroy", packageToDelete.value.id), {
|
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.reload({ only: ["packages"] });
|
router.reload({ only: ["packages"] });
|
||||||
},
|
},
|
||||||
|
|
@ -74,7 +74,7 @@ function confirmDelete() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="SMS paketi">
|
<AdminLayout title="SMS paketi">
|
||||||
<Card class="mb-4">
|
<Card class="mb-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|
@ -82,7 +82,7 @@ function confirmDelete() {
|
||||||
<PackageIcon class="h-5 w-5 text-muted-foreground" />
|
<PackageIcon class="h-5 w-5 text-muted-foreground" />
|
||||||
<CardTitle>SMS paketi</CardTitle>
|
<CardTitle>SMS paketi</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Link :href="route('packages.create')">
|
<Link :href="route('admin.packages.create')">
|
||||||
<Button>
|
<Button>
|
||||||
<PlusIcon class="h-4 w-4" />
|
<PlusIcon class="h-4 w-4" />
|
||||||
Nov paket
|
Nov paket
|
||||||
|
|
@ -109,7 +109,7 @@ function confirmDelete() {
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="packages.data"
|
:data="packages.data"
|
||||||
:meta="packages"
|
:meta="packages"
|
||||||
route-name="packages.index"
|
route-name="admin.packages.index"
|
||||||
>
|
>
|
||||||
<template #cell-name="{ row }">
|
<template #cell-name="{ row }">
|
||||||
<span class="text-sm">{{ row.name ?? "—" }}</span>
|
<span class="text-sm">{{ row.name ?? "—" }}</span>
|
||||||
|
|
@ -172,5 +172,5 @@ function confirmDelete() {
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</AppLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
import { Link, router } from "@inertiajs/vue3";
|
import { Link, router } from "@inertiajs/vue3";
|
||||||
import { onMounted, onUnmounted, ref, computed } from "vue";
|
import { onMounted, onUnmounted, ref, computed } from "vue";
|
||||||
import {
|
import {
|
||||||
|
|
@ -88,14 +88,14 @@ function reload() {
|
||||||
|
|
||||||
function dispatchPkg() {
|
function dispatchPkg() {
|
||||||
router.post(
|
router.post(
|
||||||
route("packages.dispatch", props.package.id),
|
route("admin.packages.dispatch", props.package.id),
|
||||||
{},
|
{},
|
||||||
{ onSuccess: reload }
|
{ onSuccess: reload }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function cancelPkg() {
|
function cancelPkg() {
|
||||||
router.post(
|
router.post(
|
||||||
route("packages.cancel", props.package.id),
|
route("admin.packages.cancel", props.package.id),
|
||||||
{},
|
{},
|
||||||
{ onSuccess: reload }
|
{ onSuccess: reload }
|
||||||
);
|
);
|
||||||
|
|
@ -132,7 +132,7 @@ async function copyText(text) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :title="`Paket #${package.id}`">
|
<AdminLayout :title="`Paket #${package.id}`">
|
||||||
<Card class="mb-4">
|
<Card class="mb-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|
@ -147,7 +147,7 @@ async function copyText(text) {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" as-child>
|
<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" />
|
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||||
Nazaj
|
Nazaj
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -281,7 +281,7 @@ async function copyText(text) {
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="items.data"
|
:data="items.data"
|
||||||
:meta="items"
|
:meta="items"
|
||||||
route-name="packages.show"
|
route-name="admin.packages.show"
|
||||||
:route-params="{ id: package.id }"
|
:route-params="{ id: package.id }"
|
||||||
>
|
>
|
||||||
<template #cell-target="{ row }">
|
<template #cell-target="{ row }">
|
||||||
|
|
@ -333,5 +333,5 @@ async function copyText(text) {
|
||||||
<div v-if="refreshing" class="mt-2 text-xs text-muted-foreground">
|
<div v-if="refreshing" class="mt-2 text-xs text-muted-foreground">
|
||||||
Osveževanje ...
|
Osveževanje ...
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -2,13 +2,7 @@
|
||||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
import { useForm, Link } from "@inertiajs/vue3";
|
import { useForm, Link } from "@inertiajs/vue3";
|
||||||
import { KeyRoundIcon, ArrowLeftIcon, SaveIcon } from "lucide-vue-next";
|
import { KeyRoundIcon, ArrowLeftIcon, SaveIcon } from "lucide-vue-next";
|
||||||
import {
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/Components/ui/card";
|
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import { Label } from "@/Components/ui/label";
|
import { Label } from "@/Components/ui/label";
|
||||||
|
|
@ -42,16 +36,12 @@ function submit() {
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<div class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary">
|
||||||
class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"
|
|
||||||
>
|
|
||||||
<KeyRoundIcon class="h-5 w-5" />
|
<KeyRoundIcon class="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Uredi dovoljenje</CardTitle>
|
<CardTitle>Uredi dovoljenje</CardTitle>
|
||||||
<CardDescription
|
<CardDescription>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription>
|
||||||
>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" as-child>
|
<Button variant="ghost" size="sm" as-child>
|
||||||
|
|
@ -63,6 +53,7 @@ function submit() {
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
||||||
<form @submit.prevent="submit" class="space-y-6">
|
<form @submit.prevent="submit" class="space-y-6">
|
||||||
<div class="grid sm:grid-cols-2 gap-6">
|
<div class="grid sm:grid-cols-2 gap-6">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
@ -95,19 +86,16 @@ function submit() {
|
||||||
class="flex items-center gap-2 text-sm cursor-pointer"
|
class="flex items-center gap-2 text-sm cursor-pointer"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:default-value="form.roles.includes(r.id)"
|
:value="r.id"
|
||||||
@update:model-value="
|
:checked="form.roles.includes(r.id)"
|
||||||
(checked) => {
|
@update:checked="(checked) => {
|
||||||
if (checked) form.roles.push(r.id);
|
if (checked) form.roles.push(r.id)
|
||||||
else form.roles = form.roles.filter((id) => id !== r.id);
|
else form.roles = form.roles.filter(id => id !== r.id)
|
||||||
}
|
}"
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
><span class="font-medium">{{ r.name }}</span>
|
><span class="font-medium">{{ r.name }}</span>
|
||||||
<span class="text-xs text-muted-foreground"
|
<span class="text-xs text-muted-foreground">({{ r.slug }})</span></span
|
||||||
>({{ r.slug }})</span
|
|
||||||
></span
|
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
import { useForm, Link, router } from "@inertiajs/vue3";
|
import { useForm, Link, router } from "@inertiajs/vue3";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { SearchIcon, SaveIcon, UserPlusIcon, Link2Icon } from "lucide-vue-next";
|
import { SearchIcon, SaveIcon, UserPlusIcon } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
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) {
|
function toggle(userId, roleId) {
|
||||||
const form = forms[userId];
|
const form = forms[userId];
|
||||||
const exists = form.roles.includes(roleId);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -267,24 +254,6 @@ function submitSettings(userId) {
|
||||||
<div class="text-xs text-muted-foreground font-mono">
|
<div class="text-xs text-muted-foreground font-mono">
|
||||||
{{ user.email }}
|
{{ user.email }}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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,
|
send_auto_mail: true,
|
||||||
attach_documents: false,
|
attach_documents: false,
|
||||||
attachment_document_ids: [],
|
attachment_document_ids: [],
|
||||||
call_back_at_date: null,
|
|
||||||
call_back_at_time: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -129,20 +127,6 @@ const store = async () => {
|
||||||
|
|
||||||
const isMultipleContracts = contractUuids && contractUuids.length > 1;
|
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
|
form
|
||||||
.transform((data) => ({
|
.transform((data) => ({
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -154,16 +138,11 @@ const store = async () => {
|
||||||
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
|
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
|
||||||
? data.attachment_document_ids
|
? 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), {
|
.post(route("clientCase.activity.store", props.client_case), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
close();
|
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");
|
emit("saved");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -177,22 +156,6 @@ const currentDecision = () => {
|
||||||
decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0] || null
|
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 showSendAutoMail = () => {
|
||||||
const d = currentDecision();
|
const d = currentDecision();
|
||||||
return !!(d && d.auto_mail && d.email_template_id);
|
return !!(d && d.auto_mail && d.email_template_id);
|
||||||
|
|
@ -446,26 +409,6 @@ watch(
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="space-y-2">
|
||||||
<Label for="activityAmount">Znesek</Label>
|
<Label for="activityAmount">Znesek</Label>
|
||||||
<CurrencyInput
|
<CurrencyInput
|
||||||
|
|
@ -537,7 +480,7 @@ watch(
|
||||||
/>
|
/>
|
||||||
<div class="wrap-anywhere">
|
<div class="wrap-anywhere">
|
||||||
<p>
|
<p>
|
||||||
<span>{{ doc.name }}.{{ doc.extension }}</span>
|
{{ doc.original_name || doc.name }}
|
||||||
</p>
|
</p>
|
||||||
<span class="text-xs text-gray-400"
|
<span class="text-xs text-gray-400"
|
||||||
>({{ doc.extension?.toUpperCase() || "" }},
|
>({{ doc.extension?.toUpperCase() || "" }},
|
||||||
|
|
|
||||||
|
|
@ -741,16 +741,8 @@ const copyToClipboard = async (text) => {
|
||||||
<span class="text-gray-500">D:</span>
|
<span class="text-gray-500">D:</span>
|
||||||
<span class="ml-1">{{ fmtDate(row.due_date) }}</span>
|
<span class="ml-1">{{ fmtDate(row.due_date) }}</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-if="
|
v-if="!row.due_date && (!row.amount || Number(row.amount) === 0)"
|
||||||
!row.due_date &&
|
|
||||||
(!row.amount || Number(row.amount) === 0) &&
|
|
||||||
!row.call_back_at
|
|
||||||
"
|
|
||||||
class="text-gray-400"
|
class="text-gray-400"
|
||||||
>
|
>
|
||||||
—
|
—
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
|
||||||
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
|
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
|
||||||
import PaymentDialog from "./PaymentDialog.vue";
|
import PaymentDialog from "./PaymentDialog.vue";
|
||||||
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
|
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
|
||||||
import InstallmentDialog from "./InstallmentDialog.vue";
|
|
||||||
import ViewInstallmentsDialog from "./ViewInstallmentsDialog.vue";
|
|
||||||
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
|
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
|
||||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||||
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
|
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
|
||||||
|
|
@ -33,7 +31,6 @@ import {
|
||||||
faSpinner,
|
faSpinner,
|
||||||
faTags,
|
faTags,
|
||||||
faFolderOpen,
|
faFolderOpen,
|
||||||
faArrowUp,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import EmptyState from "@/Components/EmptyState.vue";
|
import EmptyState from "@/Components/EmptyState.vue";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
|
|
@ -447,52 +444,6 @@ const closePaymentsDialog = () => {
|
||||||
selectedContract.value = null;
|
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
|
// Meta edit dialog
|
||||||
const showMetaEditDialog = ref(false);
|
const showMetaEditDialog = ref(false);
|
||||||
|
|
||||||
|
|
@ -538,7 +489,7 @@ const availableSegmentsCount = computed(() => {
|
||||||
:empty-icon="faFolderOpen"
|
:empty-icon="faFolderOpen"
|
||||||
empty-text="Ni pogodb"
|
empty-text="Ni pogodb"
|
||||||
empty-description="Za ta primer še ni ustvarjenih pogodb. Ustvarite novo pogodbo za začetek."
|
empty-description="Za ta primer še ni ustvarjenih pogodb. Ustvarite novo pogodbo za začetek."
|
||||||
:show-pagination="true"
|
:show-pagination="false"
|
||||||
:show-toolbar="true"
|
:show-toolbar="true"
|
||||||
:hoverable="true"
|
:hoverable="true"
|
||||||
>
|
>
|
||||||
|
|
@ -799,6 +750,7 @@ const availableSegmentsCount = computed(() => {
|
||||||
|
|
||||||
<!-- Add Activity -->
|
<!-- Add Activity -->
|
||||||
<ActionMenuItem
|
<ActionMenuItem
|
||||||
|
v-if="row.active"
|
||||||
:icon="faListCheck"
|
:icon="faListCheck"
|
||||||
label="Dodaj aktivnost"
|
label="Dodaj aktivnost"
|
||||||
@click="onAddActivity(row)"
|
@click="onAddActivity(row)"
|
||||||
|
|
@ -881,26 +833,6 @@ const availableSegmentsCount = computed(() => {
|
||||||
@click="openPaymentDialog(row)"
|
@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 -->
|
<!-- Archive -->
|
||||||
<template v-if="edit">
|
<template v-if="edit">
|
||||||
<div class="my-1 border-t border-gray-100" />
|
<div class="my-1 border-t border-gray-100" />
|
||||||
|
|
@ -1006,20 +938,6 @@ const availableSegmentsCount = computed(() => {
|
||||||
:edit="edit"
|
:edit="edit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InstallmentDialog
|
|
||||||
:show="showInstallmentDialog"
|
|
||||||
:form="installmentForm"
|
|
||||||
@close="closeInstallmentDialog"
|
|
||||||
@submit="submitInstallment"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ViewInstallmentsDialog
|
|
||||||
:show="showInstallmentsDialog"
|
|
||||||
:contract="selectedContract"
|
|
||||||
@close="closeInstallmentsDialog"
|
|
||||||
:edit="edit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ContractMetaEditDialog
|
<ContractMetaEditDialog
|
||||||
:show="showMetaEditDialog"
|
:show="showMetaEditDialog"
|
||||||
:client_case="client_case"
|
: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({
|
const props = defineProps({
|
||||||
client: Object,
|
client: Object,
|
||||||
client_case: 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
|
activities: Object, // Resource Collection with data/links/meta
|
||||||
contract_types: Array,
|
contract_types: Array,
|
||||||
account_types: { type: Array, default: () => [] },
|
account_types: { type: Array, default: () => [] },
|
||||||
|
|
@ -46,7 +46,7 @@ const props = defineProps({
|
||||||
|
|
||||||
// Extract contracts array from Resource Collection
|
// Extract contracts array from Resource Collection
|
||||||
const contractsArray = computed(() => {
|
const contractsArray = computed(() => {
|
||||||
return props.contracts || [];
|
return props.contracts?.data || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Contracts are always paginated now (Resource Collection)
|
// Contracts are always paginated now (Resource Collection)
|
||||||
|
|
@ -356,6 +356,19 @@ const submitAttachSegment = () => {
|
||||||
@create="openDrawerCreateContract"
|
@create="openDrawerCreateContract"
|
||||||
@attach-segment="openAttachSegment"
|
@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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const chartData = computed(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.trends.labels.map((label, i) => ({
|
return props.trends.labels.map((label, i) => ({
|
||||||
date: new Date(label + "T00:00:00"),
|
date: new Date(label),
|
||||||
dateLabel: label,
|
dateLabel: label,
|
||||||
completed: props.trends.field_jobs_completed[i] || 0,
|
completed: props.trends.field_jobs_completed[i] || 0,
|
||||||
assigned: props.trends.field_jobs[i] || 0,
|
assigned: props.trends.field_jobs[i] || 0,
|
||||||
|
|
@ -140,7 +140,7 @@ const crosshairLabelFormatter = (value) => {
|
||||||
type="x"
|
type="x"
|
||||||
:tick-line="false"
|
:tick-line="false"
|
||||||
:grid-line="false"
|
:grid-line="false"
|
||||||
:tick-values="chartData.map((d) => d.date)"
|
:num-ticks="7"
|
||||||
:tick-format="
|
:tick-format="
|
||||||
(d) => {
|
(d) => {
|
||||||
const date = new Date(d);
|
const date = new Date(d);
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,13 @@ function safeCaseHref(uuid, segment = null) {
|
||||||
v-if="fieldJobsAssignedToday && fieldJobsAssignedToday.length > 0"
|
v-if="fieldJobsAssignedToday && fieldJobsAssignedToday.length > 0"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-1 px-1">
|
<div class="flex flex-col gap-1 px-1">
|
||||||
<template v-for="f in fieldJobsAssignedToday" :key="f.id">
|
<Item
|
||||||
<Item v-if="f.contract" variant="outline" size="sm" as-child>
|
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)">
|
<a :href="safeCaseHref(f.contract.client_case_uuid, f.contract.segment_id)">
|
||||||
<ItemMedia>
|
<ItemMedia>
|
||||||
<span class="w-2 h-2 mt-2 rounded-full bg-primary" />
|
<span class="w-2 h-2 mt-2 rounded-full bg-primary" />
|
||||||
|
|
@ -93,7 +98,6 @@ function safeCaseHref(uuid, segment = null) {
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</a>
|
</a>
|
||||||
</Item>
|
</Item>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Card, CardContent } from "@/Components/ui/card";
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: String,
|
label: String,
|
||||||
value: [String, Number],
|
value: [String, Number],
|
||||||
icon: [Object, Function],
|
icon: Object,
|
||||||
iconBg: {
|
iconBg: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "bg-primary/10",
|
default: "bg-primary/10",
|
||||||
|
|
|
||||||
|
|
@ -263,14 +263,10 @@ function formatDate(value) {
|
||||||
if (isNaN(d)) {
|
if (isNaN(d)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
timeZone: "Europe/Ljubljana",
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
day: "2-digit",
|
const yyyy = d.getFullYear();
|
||||||
month: "2-digit",
|
return `${dd}.${mm}.${yyyy}`;
|
||||||
year: "numeric",
|
|
||||||
}).formatToParts(d);
|
|
||||||
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
||||||
return `${map.day}.${map.month}.${map.year}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrencyEUR(value) {
|
function formatCurrencyEUR(value) {
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ function formatDateTimeNoSeconds(value) {
|
||||||
last_page: imports?.meta?.last_page,
|
last_page: imports?.meta?.last_page,
|
||||||
from: imports?.meta?.from,
|
from: imports?.meta?.from,
|
||||||
to: imports?.meta?.to,
|
to: imports?.meta?.to,
|
||||||
links: imports?.meta?.links,
|
links: imports?.links,
|
||||||
}"
|
}"
|
||||||
route-name="imports.index"
|
route-name="imports.index"
|
||||||
:only-props="['imports']"
|
:only-props="['imports']"
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,9 @@ const props = defineProps({
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="Uvozne predloge">
|
<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="py-6">
|
||||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
|
<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 { Button } from "@/Components/ui/button";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
import { Separator } from "@/Components/ui/separator";
|
import { Separator } from "@/Components/ui/separator";
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/Components/ui/accordion";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -56,6 +50,9 @@ import {
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
Building2,
|
Building2,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
Activity,
|
Activity,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
|
|
||||||
|
|
@ -281,11 +278,16 @@ const clientSummary = computed(() => {
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<Button variant="outline" size="sm" @click="router.visit(route('phone.index'))">
|
<Button
|
||||||
<ArrowLeft />
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="router.visit(route('phone.index'))"
|
||||||
|
class="shrink-0"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="w-4 h-4 mr-1" />
|
||||||
Nazaj
|
Nazaj
|
||||||
</Button>
|
</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 }}
|
{{ client_case?.person?.full_name }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -295,7 +297,7 @@ const clientSummary = computed(() => {
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="bg-emerald-100 text-emerald-700 hover:bg-emerald-100"
|
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
|
Zaključeno danes
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -303,25 +305,25 @@ const clientSummary = computed(() => {
|
||||||
@click="confirmComplete = true"
|
@click="confirmComplete = true"
|
||||||
class="bg-green-600 hover:bg-green-700"
|
class="bg-green-600 hover:bg-green-700"
|
||||||
>
|
>
|
||||||
<CheckCircle2 class="w-4 h-4" />
|
<CheckCircle2 class="w-4 h-4 mr-2" />
|
||||||
Zaključi
|
Zaključi
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<div class="mx-auto max-w-5xl px-2 sm:px-4 space-y-4">
|
||||||
<!-- Client details (account holder) -->
|
<!-- Client details (account holder) -->
|
||||||
<Card class="p-0 py-3 gap-3">
|
<Card>
|
||||||
<CardHeader class="px-3 py-2">
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
<Building2 class="w-5 h-5 text-gray-500" />
|
<Building2 class="w-5 h-5 text-gray-500" />
|
||||||
<span class="truncate">{{ clientSummary.name }}</span>
|
<span class="truncate">{{ clientSummary.name }}</span>
|
||||||
<Badge variant="secondary">Naročnik</Badge>
|
<Badge variant="secondary">Naročnik</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="px-3">
|
<CardContent>
|
||||||
<Separator class="mb-4" />
|
<Separator class="mb-4" />
|
||||||
<PersonDetailPhone
|
<PersonDetailPhone
|
||||||
:types="types"
|
:types="types"
|
||||||
|
|
@ -332,8 +334,8 @@ const clientSummary = computed(() => {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Person (case person) -->
|
<!-- Person (case person) -->
|
||||||
<Card class="p-0 py-3 gap-3">
|
<Card>
|
||||||
<CardHeader class="px-3 py-2">
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
<User class="w-5 h-5 text-gray-500" />
|
<User class="w-5 h-5 text-gray-500" />
|
||||||
<span class="truncate">{{ client_case.person.full_name }}</span>
|
<span class="truncate">{{ client_case.person.full_name }}</span>
|
||||||
|
|
@ -345,15 +347,8 @@ const clientSummary = computed(() => {
|
||||||
>
|
>
|
||||||
{{ client_case.person.description }}
|
{{ client_case.person.description }}
|
||||||
</CardDescription>
|
</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>
|
</CardHeader>
|
||||||
<CardContent class="px-3">
|
<CardContent>
|
||||||
<Separator class="mb-4" />
|
<Separator class="mb-4" />
|
||||||
<PersonDetailPhone
|
<PersonDetailPhone
|
||||||
:types="types"
|
:types="types"
|
||||||
|
|
@ -364,185 +359,75 @@ const clientSummary = computed(() => {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Contracts assigned to me -->
|
<!-- Contracts assigned to me -->
|
||||||
<Card class="p-0 py-3 gap-1">
|
<Card>
|
||||||
<CardHeader class="px-3 py-2 pb-0">
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2">
|
<CardTitle class="flex items-center gap-2">
|
||||||
<FileText class="w-5 h-5" />
|
<FileText class="w-5 h-5" />
|
||||||
Pogodbe
|
Pogodbe
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-2 space-y-1">
|
<CardContent class="space-y-3">
|
||||||
<Card
|
<Card
|
||||||
v-for="c in contracts"
|
v-for="c in contracts"
|
||||||
:key="c.uuid || c.id"
|
: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="pb-3">
|
||||||
<CardHeader class="p-3 pb-2 gap-0">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="flex items-center flex-wrap">
|
<div class="min-w-0 flex-1">
|
||||||
<CardTitle class="text-base font-semibold">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
{{ c.reference || "Šifra pogodbe ni določena" }}
|
<CardTitle class="text-sm">
|
||||||
|
{{ c.reference || c.uuid }}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<Badge v-if="c.type?.name" variant="secondary" class="text-[11px]">
|
||||||
|
{{ c.type.name }}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<div v-if="c.account" class="mt-3 flex items-center gap-2">
|
||||||
|
<Euro class="w-4 h-4 text-gray-400" />
|
||||||
<!-- Balance row -->
|
<div class="flex items-baseline gap-2">
|
||||||
<div
|
<span class="text-xs text-gray-500 uppercase">Odprto</span>
|
||||||
v-if="c.account"
|
|
||||||
class="mx-3 rounded-xl bg-red-50 dark:bg-red-950/20 border border-red-100 dark:border-red-900 px-2 py-2 flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 text-red-500">
|
|
||||||
<Euro class="w-4 h-4 shrink-0" />
|
|
||||||
<span class="text-xs font-medium uppercase tracking-wide text-red-400"
|
|
||||||
>Odprto</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<span
|
<span
|
||||||
class="text-2xl font-bold text-red-600 dark:text-red-400 tabular-nums"
|
class="text-lg font-semibold text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
{{ formatAmount(c.account.balance_amount) }} €
|
{{ formatAmount(c.account.balance_amount) }} €
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</AccordionTrigger>
|
</div>
|
||||||
<AccordionContent class="pb-2">
|
<div class="flex flex-col gap-2 shrink-0">
|
||||||
<div
|
<Button size="sm" @click="openDrawerAddActivity(c)">
|
||||||
class="divide-y divide-gray-100 dark:divide-gray-700 rounded-lg border border-gray-100 dark:border-gray-700 overflow-hidden"
|
<Plus class="w-4 h-4 mr-1" />
|
||||||
>
|
Aktivnost
|
||||||
<div
|
</Button>
|
||||||
v-for="(val, key) in c.meta"
|
<Button size="sm" variant="secondary" @click="openDocDialog(c)">
|
||||||
:key="key"
|
<Upload class="w-4 h-4 mr-1" />
|
||||||
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"
|
Dokument
|
||||||
>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent v-if="c.last_object" class="pt-0">
|
||||||
|
<Separator class="mb-3" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-xs text-gray-500 uppercase">Zadnji predmet</p>
|
||||||
|
<div class="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{{ c.last_object.name || c.last_object.reference }}
|
||||||
<span
|
<span
|
||||||
class="text-xs text-gray-500 dark:text-gray-400 shrink-0"
|
v-if="c.last_object.type"
|
||||||
>{{ val?.title || key }}</span
|
class="ml-2 text-xs font-normal text-gray-500"
|
||||||
>
|
>
|
||||||
<span
|
({{ c.last_object.type }})
|
||||||
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>
|
</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line rounded-lg bg-gray-50 dark:bg-gray-800/50 px-3 py-2.5"
|
v-if="c.last_object.description"
|
||||||
|
class="text-sm text-gray-600 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">
|
{{ c.last_object.description }}
|
||||||
{{ c.latest_object.name || c.latest_object.reference }}
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</template>
|
|
||||||
</CardContent>
|
</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>
|
</Card>
|
||||||
<p
|
<p
|
||||||
v-if="!contracts?.length"
|
v-if="!contracts?.length"
|
||||||
|
|
@ -554,27 +439,27 @@ const clientSummary = computed(() => {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Activities -->
|
<!-- Activities -->
|
||||||
<Card class="p-0 py-2 gap-2">
|
<Card>
|
||||||
<CardHeader class="px-3 py-2">
|
<CardHeader>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<CardTitle class="flex items-center gap-2">
|
<CardTitle class="flex items-center gap-2">
|
||||||
<Activity class="w-5 h-5" />
|
<Activity class="w-5 h-5" />
|
||||||
Aktivnosti
|
Aktivnosti
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button size="sm" @click="openDrawerAddActivity()">
|
<Button size="sm" @click="openDrawerAddActivity()">
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4 mr-1" />
|
||||||
Nova
|
Nova
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-1 px-2">
|
<CardContent class="space-y-3">
|
||||||
<Card
|
<Card
|
||||||
v-for="a in activities"
|
v-for="a in activities"
|
||||||
:key="a.id"
|
: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">
|
<CardHeader>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<CardTitle class="text-sm font-medium truncate">
|
<CardTitle class="text-sm font-medium truncate">
|
||||||
{{ activityActionLine(a) || "Aktivnost" }}
|
{{ activityActionLine(a) || "Aktivnost" }}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|
@ -595,7 +480,7 @@ const clientSummary = computed(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</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">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
<Badge v-if="a.contract" variant="secondary" class="text-[10px]">
|
<Badge v-if="a.contract" variant="secondary" class="text-[10px]">
|
||||||
<FileText class="w-3 h-3 mr-1" />
|
<FileText class="w-3 h-3 mr-1" />
|
||||||
|
|
@ -621,10 +506,7 @@ const clientSummary = computed(() => {
|
||||||
{{ a.status }}
|
{{ a.status }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p v-if="a.note" class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{{ a.note }}
|
{{ a.note }}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -639,8 +521,8 @@ const clientSummary = computed(() => {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Documents (case + assigned contracts) -->
|
<!-- Documents (case + assigned contracts) -->
|
||||||
<Card class="p-0 py-2 gap-2">
|
<Card>
|
||||||
<CardHeader class="px-3 py-2">
|
<CardHeader>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<CardTitle class="flex items-center gap-2">
|
<CardTitle class="flex items-center gap-2">
|
||||||
<FileText class="w-5 h-5" />
|
<FileText class="w-5 h-5" />
|
||||||
|
|
@ -664,7 +546,7 @@ const clientSummary = computed(() => {
|
||||||
{{ d.name || d.original_name }}
|
{{ d.name || d.original_name }}
|
||||||
</div>
|
</div>
|
||||||
<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
|
<Badge
|
||||||
v-if="d.contract_reference"
|
v-if="d.contract_reference"
|
||||||
|
|
@ -674,11 +556,6 @@ const clientSummary = computed(() => {
|
||||||
Pogodba: {{ d.contract_reference }}
|
Pogodba: {{ d.contract_reference }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-else variant="outline" class="text-[10px]"> Primer </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">
|
<span v-if="d.created_at" class="flex items-center gap-1">
|
||||||
<Calendar class="w-3 h-3" />
|
<Calendar class="w-3 h-3" />
|
||||||
{{ new Date(d.created_at).toLocaleDateString("sl-SI") }}
|
{{ new Date(d.created_at).toLocaleDateString("sl-SI") }}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,13 @@
|
||||||
import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue";
|
import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { Badge } from "@/Components/ui/badge";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/Components/ui/card";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -10,121 +17,91 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/Components/ui/select";
|
} from "@/Components/ui/select";
|
||||||
|
import { Separator } from "@/Components/ui/separator";
|
||||||
import { Skeleton } from "@/Components/ui/skeleton";
|
import { Skeleton } from "@/Components/ui/skeleton";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
|
import { router } from "@inertiajs/vue3";
|
||||||
import { InfiniteScroll, router } from "@inertiajs/vue3";
|
import { computed, ref, watch } from "vue";
|
||||||
import {
|
|
||||||
computed,
|
|
||||||
defineComponent,
|
|
||||||
h,
|
|
||||||
onMounted,
|
|
||||||
onUnmounted,
|
|
||||||
ref,
|
|
||||||
watch,
|
|
||||||
} from "vue";
|
|
||||||
import { useDebounceFn } from "@vueuse/core";
|
import { useDebounceFn } from "@vueuse/core";
|
||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
FileText,
|
||||||
ChevronRight,
|
FilterIcon,
|
||||||
ClipboardList,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
SlidersHorizontal,
|
|
||||||
Wallet,
|
Wallet,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import { fmtDateDMY } from "@/Utilities/functions";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
pendingJobs: { type: Object, default: null },
|
jobs: { type: Object, required: true },
|
||||||
processedJobs: { type: Object, default: null },
|
|
||||||
completedJobs: { type: Object, default: null },
|
|
||||||
clients: { type: Array, default: () => [] },
|
clients: { type: Array, default: () => [] },
|
||||||
view_mode: { type: String, default: "assigned" },
|
view_mode: { type: String, default: "assigned" },
|
||||||
filters: { type: Object, default: () => ({ search: "", client: "" }) },
|
filters: { type: Object, default: () => ({ search: "", client: "" }) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCompleted = computed(() => props.view_mode === "completed-today");
|
|
||||||
|
|
||||||
// ── Filters ──────────────────────────────────────────────────────────────────
|
|
||||||
const search = ref(props.filters.search || "");
|
const search = ref(props.filters.search || "");
|
||||||
const clientFilter = ref(props.filters.client || "all");
|
const clientFilter = ref(props.filters.client || "");
|
||||||
const isFiltering = ref(false);
|
const isLoading = ref(false);
|
||||||
const showFilters = ref(
|
|
||||||
!!(props.filters.search || (props.filters.client && props.filters.client !== "all"))
|
const listNonActivity = computed(() =>
|
||||||
|
(props.jobs.data || []).filter((item) => !item.added_activity)
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedFilter = useDebounceFn(() => performFilter(), 500);
|
const listActivity = computed(() =>
|
||||||
watch(search, () => debouncedFilter());
|
(props.jobs.data || []).filter((item) => !!item.added_activity)
|
||||||
watch(clientFilter, () => performFilter());
|
);
|
||||||
|
|
||||||
function performFilter() {
|
const debouncedSearch = useDebounceFn((value) => {
|
||||||
isFiltering.value = true;
|
performSearch();
|
||||||
const targetRoute = isCompleted.value ? "phone.completed" : "phone.index";
|
}, 500);
|
||||||
const only = isCompleted.value
|
|
||||||
? ["completedJobs", "filters"]
|
watch(search, (newValue) => {
|
||||||
: ["pendingJobs", "processedJobs", "filters"];
|
debouncedSearch(newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(clientFilter, () => {
|
||||||
|
performSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
function performSearch() {
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
route(targetRoute),
|
route(props.view_mode === "completed-today" ? "phone.completed" : "phone.index"),
|
||||||
{
|
{
|
||||||
search: search.value || undefined,
|
search: search.value || undefined,
|
||||||
client: clientFilter.value !== "all" ? clientFilter.value : undefined,
|
client: clientFilter.value || undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
preserveState: true,
|
preserveState: true,
|
||||||
preserveScroll: false,
|
preserveScroll: true,
|
||||||
only,
|
only: ["jobs", "filters"],
|
||||||
reset: isCompleted.value
|
onFinish: () => {
|
||||||
? ["completedJobs"]
|
isLoading.value = false;
|
||||||
: ["pendingJobs", "processedJobs"],
|
|
||||||
onSuccess: () => {
|
|
||||||
isFiltering.value = false;
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
isFiltering.value = false;
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearSearch() {
|
||||||
search.value = "";
|
search.value = "";
|
||||||
clientFilter.value = "all";
|
clientFilter.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scroll-hide title ────────────────────────────────────────────────────────
|
function formatDateDMY(d) {
|
||||||
const scrolled = ref(false);
|
if (!d) return "-";
|
||||||
let stopNavigateListener = null;
|
if (/^\d{4}-\d{2}-\d{2}/.test(d)) {
|
||||||
function onScroll() {
|
const [y, m, rest] = d.split("-");
|
||||||
if (!scrolled.value && window.scrollY > 50) {
|
const day = (rest || "").slice(0, 2) || "01";
|
||||||
scrolled.value = true;
|
return `${day}.${m}.${y}`;
|
||||||
} else if (scrolled.value && window.scrollY < 10) {
|
|
||||||
scrolled.value = false;
|
|
||||||
}
|
}
|
||||||
|
const dt = new Date(d);
|
||||||
|
if (Number.isNaN(dt.getTime())) return String(d);
|
||||||
|
const dd = String(dt.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(dt.getMonth() + 1).padStart(2, "0");
|
||||||
|
const yyyy = dt.getFullYear();
|
||||||
|
return `${dd}.${mm}.${yyyy}`;
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
|
||||||
let trackedPath = window.location.pathname;
|
|
||||||
stopNavigateListener = router.on("navigate", (event) => {
|
|
||||||
const newPath = new URL(event.detail.page.url, window.location.origin).pathname;
|
|
||||||
if (newPath !== trackedPath) {
|
|
||||||
scrolled.value = false;
|
|
||||||
trackedPath = newPath;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (stopNavigateListener) stopNavigateListener();
|
|
||||||
window.removeEventListener("scroll", onScroll);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Counts ───────────────────────────────────────────────────────────────────
|
|
||||||
const pendingCount = computed(() => props.pendingJobs?.total ?? 0);
|
|
||||||
const processedCount = computed(() => props.processedJobs?.total ?? 0);
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
function formatAmount(val) {
|
function formatAmount(val) {
|
||||||
if (val === null || val === undefined) return "0,00";
|
if (val === null || val === undefined) return "0,00";
|
||||||
const num = typeof val === "number" ? val : parseFloat(val);
|
const num = typeof val === "number" ? val : parseFloat(val);
|
||||||
|
|
@ -141,247 +118,58 @@ function getCaseUuid(job) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobHref(job) {
|
function changePage(url) {
|
||||||
const uuid = getCaseUuid(job);
|
if (!url) return;
|
||||||
if (!uuid) return null;
|
isLoading.value = true;
|
||||||
return route("phone.case", {
|
router.get(
|
||||||
client_case: uuid,
|
url,
|
||||||
completed: isCompleted.value ? 1 : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── JobCard component ────────────────────────────────────────────────────────
|
|
||||||
const JobCard = defineComponent({
|
|
||||||
name: "JobCard",
|
|
||||||
props: {
|
|
||||||
job: { type: Object, required: true },
|
|
||||||
href: { type: String, default: null },
|
|
||||||
accentClass: { type: String, default: "border-l-blue-500" },
|
|
||||||
showLastActivity: { type: Boolean, default: false },
|
|
||||||
},
|
|
||||||
setup(p) {
|
|
||||||
return () => {
|
|
||||||
const j = p.job;
|
|
||||||
const person = j.contract?.client_case?.person;
|
|
||||||
const clientName = j.contract?.client_case?.client?.person?.full_name;
|
|
||||||
const address = person?.address?.address;
|
|
||||||
const phone = person?.phones?.[0]?.nu;
|
|
||||||
const balance = j.contract?.account?.balance_amount;
|
|
||||||
|
|
||||||
const inner = h("div", { class: `border-l-4 ${p.accentClass}` }, [
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
class: "px-4 pt-4 pb-2 flex items-start justify-between gap-3",
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h("div", { class: "flex-1 min-w-0" }, [
|
|
||||||
h(
|
|
||||||
"p",
|
|
||||||
{
|
|
||||||
class:
|
|
||||||
"font-bold text-base text-gray-900 dark:text-gray-100 truncate leading-tight",
|
|
||||||
},
|
|
||||||
person?.full_name || "—"
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
"p",
|
|
||||||
{
|
|
||||||
class: "text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate",
|
|
||||||
},
|
|
||||||
j.contract?.reference || j.contract?.uuid || "—"
|
|
||||||
),
|
|
||||||
clientName
|
|
||||||
? h(
|
|
||||||
"p",
|
|
||||||
{
|
|
||||||
class:
|
|
||||||
"text-xs text-indigo-600 dark:text-indigo-400 mt-0.5 truncate",
|
|
||||||
},
|
|
||||||
clientName
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
]),
|
|
||||||
j.priority
|
|
||||||
? h(
|
|
||||||
"span",
|
|
||||||
{
|
|
||||||
class:
|
|
||||||
"shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400 animate-pulse",
|
|
||||||
},
|
|
||||||
"Prioriteta"
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
]
|
|
||||||
),
|
|
||||||
address || phone
|
|
||||||
? h("div", { class: "px-4 pb-3 space-y-1.5" }, [
|
|
||||||
address
|
|
||||||
? h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
class: "flex items-center gap-2 text-gray-500 dark:text-gray-400",
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h(MapPin, {
|
|
||||||
class: "w-3.5 h-3.5 shrink-0 text-gray-400",
|
|
||||||
}),
|
|
||||||
h("span", { class: "text-xs truncate" }, address),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
phone
|
|
||||||
? h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
class: "flex items-center gap-2 text-gray-500 dark:text-gray-400",
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h(Phone, {
|
|
||||||
class: "w-3.5 h-3.5 shrink-0 text-gray-400",
|
|
||||||
}),
|
|
||||||
h("span", { class: "text-xs font-medium" }, phone),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
])
|
|
||||||
: null,
|
|
||||||
balance != null
|
|
||||||
? h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
class:
|
|
||||||
"mx-4 mb-3 px-3 py-2 bg-red-50 dark:bg-red-950/20 rounded-lg flex items-center gap-2 border border-red-100 dark:border-red-900",
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h(Wallet, {
|
|
||||||
class: "w-4 h-4 text-red-500 shrink-0",
|
|
||||||
}),
|
|
||||||
h(
|
|
||||||
"span",
|
|
||||||
{
|
|
||||||
class: "font-bold text-red-600 dark:text-red-400 text-sm",
|
|
||||||
},
|
|
||||||
`${formatAmount(balance)} €`
|
|
||||||
),
|
|
||||||
h("span", { class: "text-xs text-red-400" }, "odprto"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
class:
|
|
||||||
"px-4 py-3 border-t bg-gray-50/60 dark:bg-gray-900/40 flex items-center justify-between",
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
class: "flex items-center gap-1.5 text-xs text-gray-400",
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h(CalendarDays, { class: "w-3.5 h-3.5" }),
|
|
||||||
h(
|
|
||||||
"span",
|
|
||||||
{},
|
{},
|
||||||
p.showLastActivity && j.last_activity
|
|
||||||
? fmtDateDMY(j.last_activity)
|
|
||||||
: fmtDateDMY(j.assigned_at)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
p.href
|
|
||||||
? h(
|
|
||||||
"div",
|
|
||||||
{
|
{
|
||||||
class: "flex items-center gap-0.5 text-primary font-semibold text-sm",
|
preserveState: true,
|
||||||
|
preserveScroll: false,
|
||||||
|
only: ["jobs"],
|
||||||
|
onFinish: () => {
|
||||||
|
isLoading.value = false;
|
||||||
},
|
},
|
||||||
["Odpri", h(ChevronRight, { class: "w-4 h-4" })]
|
}
|
||||||
)
|
|
||||||
: h("span", { class: "text-xs text-gray-400 italic" }, "Manjka primer"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return p.href
|
|
||||||
? h(
|
|
||||||
"a",
|
|
||||||
{
|
|
||||||
href: p.href,
|
|
||||||
class:
|
|
||||||
"block rounded-xl border shadow-sm overflow-hidden bg-white dark:bg-card active:scale-[0.99] transition-transform duration-100",
|
|
||||||
},
|
|
||||||
inner
|
|
||||||
)
|
|
||||||
: h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
class:
|
|
||||||
"rounded-xl border shadow-sm overflow-hidden bg-white dark:bg-card opacity-60",
|
|
||||||
},
|
|
||||||
inner
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPhoneLayout title="Phone">
|
<AppPhoneLayout title="Phone">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2
|
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||||
class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight overflow-hidden transition-all duration-200 ease-in-out"
|
{{
|
||||||
:class="scrolled ? 'max-h-0 opacity-0 mb-0' : 'max-h-12 opacity-100 mb-0'"
|
props.view_mode === "completed-today" ? "Zaključeno danes" : "Terenska opravila"
|
||||||
>
|
}}
|
||||||
{{ isCompleted ? "Zaključeno danes" : "Terenska opravila" }}
|
|
||||||
</h2>
|
</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Filter bar -->
|
<div class="py-6 sm:py-8">
|
||||||
<div class="pt-2 space-y-2">
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 space-y-4">
|
||||||
<div class="flex items-center gap-2">
|
<!-- Filters Section -->
|
||||||
<div class="relative flex-1">
|
<Card>
|
||||||
|
<CardHeader class="flex flex-row gap-1 items-center">
|
||||||
|
<FilterIcon size="20" />
|
||||||
|
<CardTitle class="text-xl">Filter</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
<Input
|
<Input
|
||||||
v-model="search"
|
v-model="search"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Išči po referenci ali imenu..."
|
placeholder="Išči po referenci ali imenu..."
|
||||||
class="pr-10"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
v-if="isFiltering"
|
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
class="shrink-0"
|
|
||||||
:class="
|
|
||||||
showFilters || clientFilter !== 'all' ? 'bg-primary/10 border-primary' : ''
|
|
||||||
"
|
|
||||||
title="Filter po naročniku"
|
|
||||||
@click="showFilters = !showFilters"
|
|
||||||
>
|
|
||||||
<SlidersHorizontal class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
v-if="search || clientFilter !== 'all'"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="shrink-0 text-muted-foreground"
|
|
||||||
@click="clearFilters"
|
|
||||||
>
|
|
||||||
Počisti
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showFilters || clientFilter !== 'all'">
|
|
||||||
<Select v-model="clientFilter">
|
<Select v-model="clientFilter">
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="w-full sm:w-64">
|
||||||
<SelectValue placeholder="Vsi naročniki" />
|
<SelectValue placeholder="Vsi naročniki" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Vsi naročniki</SelectItem>
|
|
||||||
<SelectItem
|
<SelectItem
|
||||||
v-for="client in props.clients"
|
v-for="client in props.clients"
|
||||||
:key="client.uuid"
|
:key="client.uuid"
|
||||||
|
|
@ -391,140 +179,405 @@ const JobCard = defineComponent({
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="pb-8">
|
<Button
|
||||||
<!-- Assigned mode: two tabs (Novo / Obdelano) -->
|
v-if="search || clientFilter"
|
||||||
<div v-if="!isCompleted" class="px-4 pt-4">
|
variant="outline"
|
||||||
<Tabs default-value="pending" class="w-full">
|
@click="clearSearch"
|
||||||
<TabsList class="w-full grid grid-cols-2 mb-4">
|
>
|
||||||
<TabsTrigger value="pending">
|
Počisti
|
||||||
<span class="inline-flex flex-row items-center gap-1">
|
</Button>
|
||||||
<ClipboardList class="w-3.5 h-3.5 shrink-0" />
|
</div>
|
||||||
Novo
|
</CardContent>
|
||||||
<Badge v-if="pendingCount" variant="secondary" class="h-4 px-1 text-xs">
|
</Card>
|
||||||
{{ pendingCount }}
|
|
||||||
|
<!-- Loading Skeleton -->
|
||||||
|
<div v-if="isLoading" class="space-y-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Skeleton class="h-8 w-48" />
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Skeleton v-for="i in 6" :key="i" class="h-72" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else-if="props.jobs.data && props.jobs.data.length" class="space-y-8">
|
||||||
|
<!-- Non-Activity Jobs -->
|
||||||
|
<section v-if="listNonActivity.length" class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Nove / Ne obdelano
|
||||||
|
</h2>
|
||||||
|
<Badge variant="secondary" class="text-sm">
|
||||||
|
{{ listNonActivity.length }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Card
|
||||||
|
v-for="job in listNonActivity"
|
||||||
|
:key="job.id"
|
||||||
|
class="flex flex-col hover:shadow-xl transition-all duration-200 border-l-2 border-l-blue-500"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3
|
||||||
|
class="text-xl font-bold text-gray-900 dark:text-gray-100 truncate mb-1"
|
||||||
|
>
|
||||||
|
{{ job.contract?.client_case?.person?.full_name || "—" }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="font-semibold text-indigo-600 dark:text-indigo-400"
|
||||||
|
>Naročnik:</span
|
||||||
|
>
|
||||||
|
<span class="ml-1 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
{{
|
||||||
|
job.contract?.client_case?.client?.person?.full_name || "—"
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</p>
|
||||||
<TabsTrigger value="processed">
|
</div>
|
||||||
<span class="inline-flex flex-row items-center gap-1">
|
<Badge
|
||||||
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
|
v-if="job.priority"
|
||||||
Obdelano
|
variant="destructive"
|
||||||
<Badge v-if="processedCount" variant="secondary" class="h-4 px-1 text-xs">
|
class="shrink-0 animate-pulse"
|
||||||
{{ processedCount }}
|
>
|
||||||
|
Prioriteta
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="flex-1 space-y-3 pt-4">
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<CalendarDays class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Dodeljeno
|
||||||
|
</p>
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatDateDMY(job.assigned_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<FileText class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Pogodba
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="font-mono text-xs text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded inline-block"
|
||||||
|
>
|
||||||
|
{{ job.contract?.reference || job.contract?.uuid }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<MapPin class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Naslov
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-900 dark:text-gray-100">
|
||||||
|
{{ job.contract?.client_case?.person?.address?.address || "—" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Phone class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Telefon
|
||||||
|
</p>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || "—" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="job.contract?.account?.balance_amount != null"
|
||||||
|
class="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 mt-4"
|
||||||
|
>
|
||||||
|
<Wallet
|
||||||
|
class="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Odprto
|
||||||
|
</p>
|
||||||
|
<p class="font-bold text-red-700 dark:text-red-400 text-lg">
|
||||||
|
{{ formatAmount(job.contract.account.balance_amount) }} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter class="pt-4 mt-auto">
|
||||||
|
<Button
|
||||||
|
v-if="getCaseUuid(job)"
|
||||||
|
as="a"
|
||||||
|
:href="
|
||||||
|
route('phone.case', {
|
||||||
|
client_case: getCaseUuid(job),
|
||||||
|
completed: props.view_mode === 'completed-today' ? 1 : undefined,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="w-full font-semibold"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
Odpri primer
|
||||||
|
</Button>
|
||||||
|
<Button v-else disabled class="w-full" variant="secondary" size="lg">
|
||||||
|
Manjka primer
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Activity Jobs -->
|
||||||
|
<section v-if="listActivity.length" class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Obdelane pogodbe
|
||||||
|
</h2>
|
||||||
|
<Badge variant="secondary" class="text-sm">
|
||||||
|
{{ listActivity.length }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Card
|
||||||
|
v-for="job in listActivity"
|
||||||
|
:key="job.id"
|
||||||
|
class="flex flex-col hover:shadow-xl transition-all duration-200 border-l-2 border-l-green-500"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3
|
||||||
|
class="text-xl font-bold text-gray-900 dark:text-gray-100 truncate mb-1"
|
||||||
|
>
|
||||||
|
{{ job.contract?.client_case?.person?.full_name || "—" }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="font-semibold text-indigo-600 dark:text-indigo-400"
|
||||||
|
>Naročnik:</span
|
||||||
|
>
|
||||||
|
<span class="ml-1 font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
{{
|
||||||
|
job.contract?.client_case?.client?.person?.full_name || "—"
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</p>
|
||||||
</TabsList>
|
</div>
|
||||||
|
<Badge
|
||||||
<!-- Pending tab -->
|
v-if="job.priority"
|
||||||
<TabsContent value="pending" class="space-y-3">
|
variant="destructive"
|
||||||
<InfiniteScroll data="pendingJobs" only-next>
|
class="shrink-0 animate-pulse"
|
||||||
<template #default="{ loading }">
|
|
||||||
<template v-if="props.pendingJobs?.data?.length">
|
|
||||||
<JobCard
|
|
||||||
v-for="job in props.pendingJobs.data"
|
|
||||||
:key="job.id"
|
|
||||||
:job="job"
|
|
||||||
:href="jobHref(job)"
|
|
||||||
accent-class="border-l-blue-500"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<div
|
|
||||||
v-else-if="!loading"
|
|
||||||
class="py-16 text-center text-gray-500 dark:text-gray-400 space-y-2"
|
|
||||||
>
|
>
|
||||||
<ClipboardList class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto" />
|
Prioriteta
|
||||||
<p class="text-sm">
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="flex-1 space-y-3 pt-4">
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<CalendarDays class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Dodeljeno
|
||||||
|
</p>
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatDateDMY(job.assigned_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-3 p-2 bg-green-50 dark:bg-green-900/20 rounded"
|
||||||
|
>
|
||||||
|
<CalendarDays
|
||||||
|
class="w-5 h-5 text-green-600 dark:text-green-400 shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-green-600 dark:text-green-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Zadnja aktivnost
|
||||||
|
</p>
|
||||||
|
<p class="font-semibold text-green-700 dark:text-green-400">
|
||||||
|
{{ formatDateDMY(job.last_activity) || "—" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<FileText class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Kontrakt
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="font-mono text-xs text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded inline-block"
|
||||||
|
>
|
||||||
|
{{ job.contract?.reference || job.contract?.uuid }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<MapPin class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Naslov
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-900 dark:text-gray-100">
|
||||||
|
{{ job.contract?.client_case?.person?.address?.address || "—" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Phone class="w-5 h-5 text-gray-400 shrink-0 mt-0.5" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Telefon
|
||||||
|
</p>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || "—" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="job.contract?.account?.balance_amount != null"
|
||||||
|
class="flex items-start gap-3 p-3 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 mt-4"
|
||||||
|
>
|
||||||
|
<Wallet
|
||||||
|
class="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide mb-0.5"
|
||||||
|
>
|
||||||
|
Odprto
|
||||||
|
</p>
|
||||||
|
<p class="font-bold text-red-700 dark:text-red-400 text-lg">
|
||||||
|
{{ formatAmount(job.contract.account.balance_amount) }} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter class="pt-4 mt-auto">
|
||||||
|
<Button
|
||||||
|
v-if="getCaseUuid(job)"
|
||||||
|
as="a"
|
||||||
|
:href="
|
||||||
|
route('phone.case', {
|
||||||
|
client_case: getCaseUuid(job),
|
||||||
|
completed: props.view_mode === 'completed-today' ? 1 : undefined,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="w-full font-semibold"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
Odpri primer
|
||||||
|
</Button>
|
||||||
|
<Button v-else disabled class="w-full" variant="secondary" size="lg">
|
||||||
|
Manjka primer
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<Card v-if="props.jobs.links && props.jobs.links.length > 3">
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="(link, index) in props.jobs.links"
|
||||||
|
:key="index"
|
||||||
|
:variant="link.active ? 'default' : 'outline'"
|
||||||
|
:disabled="!link.url"
|
||||||
|
@click="changePage(link.url)"
|
||||||
|
size="sm"
|
||||||
|
v-html="link.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<Card v-else>
|
||||||
|
<CardContent class="py-12">
|
||||||
|
<div class="text-center space-y-3">
|
||||||
|
<div
|
||||||
|
class="mx-auto w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ search || clientFilter ? "Ni zadetkov" : "Ni opravil" }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{{
|
{{
|
||||||
search || clientFilter !== "all" ? "Ni zadetkov" : "Ni novih opravil"
|
search || clientFilter
|
||||||
|
? "Poskusite spremeniti iskalne kriterije"
|
||||||
|
: "Trenutno nimate dodeljenih terenskih opravil"
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</CardContent>
|
||||||
<template #loading>
|
</Card>
|
||||||
<div class="space-y-3">
|
|
||||||
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</InfiniteScroll>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<!-- Processed tab -->
|
|
||||||
<TabsContent value="processed" class="space-y-3">
|
|
||||||
<InfiniteScroll data="processedJobs" only-next>
|
|
||||||
<template #default="{ loading }">
|
|
||||||
<template v-if="props.processedJobs?.data?.length">
|
|
||||||
<JobCard
|
|
||||||
v-for="job in props.processedJobs.data"
|
|
||||||
:key="job.id"
|
|
||||||
:job="job"
|
|
||||||
:href="jobHref(job)"
|
|
||||||
accent-class="border-l-green-500"
|
|
||||||
:show-last-activity="true"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<div
|
|
||||||
v-else-if="!loading"
|
|
||||||
class="py-16 text-center text-gray-500 dark:text-gray-400 space-y-2"
|
|
||||||
>
|
|
||||||
<CheckCircle2 class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto" />
|
|
||||||
<p class="text-sm">
|
|
||||||
{{
|
|
||||||
search || clientFilter !== "all"
|
|
||||||
? "Ni zadetkov"
|
|
||||||
: "Ni obdelanih opravil"
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #loading>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</InfiniteScroll>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Completed-today mode: single scroll list -->
|
|
||||||
<div v-else class="px-4 pt-4 space-y-3">
|
|
||||||
<InfiniteScroll data="completedJobs" only-next>
|
|
||||||
<template #default="{ loading }">
|
|
||||||
<template v-if="props.completedJobs?.data?.length">
|
|
||||||
<JobCard
|
|
||||||
v-for="job in props.completedJobs.data"
|
|
||||||
:key="job.id"
|
|
||||||
:job="job"
|
|
||||||
:href="jobHref(job)"
|
|
||||||
accent-class="border-l-purple-500"
|
|
||||||
:show-last-activity="true"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<div
|
|
||||||
v-else-if="!loading"
|
|
||||||
class="py-16 text-center text-gray-500 dark:text-gray-400 space-y-2"
|
|
||||||
>
|
|
||||||
<CheckCircle2 class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto" />
|
|
||||||
<p class="text-sm">
|
|
||||||
{{
|
|
||||||
search || clientFilter !== "all"
|
|
||||||
? "Ni zadetkov"
|
|
||||||
: "Danes ni zaključenih opravil"
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #loading>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</InfiniteScroll>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppPhoneLayout>
|
</AppPhoneLayout>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<script setup>
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
import {
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/Components/ui/card";
|
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Link } from "@inertiajs/vue3";
|
import { Link } from "@inertiajs/vue3";
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,62 +12,48 @@ import {
|
||||||
Archive,
|
Archive,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CalendarDays,
|
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
|
|
||||||
const settingsCards = [
|
const settingsCards = [
|
||||||
{
|
{
|
||||||
title: "Segmenti",
|
title: "Segments",
|
||||||
description: "Upravljanje segmentov, ki se uporabljajo v aplikaciji.",
|
description: "Manage segments used across the app.",
|
||||||
route: "settings.segments",
|
route: "settings.segments",
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Plačila",
|
title: "Payments",
|
||||||
description: "Privzete nastavitve za plačila in samodejne aktivnosti.",
|
description: "Defaults for payments and auto-activity.",
|
||||||
route: "settings.payment.edit",
|
route: "settings.payment.edit",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Obroki",
|
title: "Workflow",
|
||||||
description: "Privzete nastavitve za obroke in samodejne aktivnosti.",
|
description: "Configure actions and decisions relationships.",
|
||||||
route: "settings.installment.edit",
|
|
||||||
icon: CalendarDays,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Potek dela",
|
|
||||||
description: "Konfiguracija akcij in odločitev.",
|
|
||||||
route: "settings.workflow",
|
route: "settings.workflow",
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Nastavitve terenskega dela",
|
title: "Field Job Settings",
|
||||||
description: "Konfiguracija pravil terenskega dela po segmentih.",
|
description: "Configure segment-based field job rules.",
|
||||||
route: "settings.fieldjob.index",
|
route: "settings.fieldjob.index",
|
||||||
icon: Briefcase,
|
icon: Briefcase,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Konfiguracije pogodb",
|
title: "Contract Configs",
|
||||||
description: "Samodejna dodelitev začetnih segmentov pogodbam glede na vrsto.",
|
description: "Auto-assign initial segments for contracts by type.",
|
||||||
route: "settings.contractConfigs.index",
|
route: "settings.contractConfigs.index",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Nastavitve pogodb",
|
title: "Archive Settings",
|
||||||
description: "Sprožilci samodejnih aktivnosti ob spremembi stanja pogodbe.",
|
description: "Define rules for archiving or soft-deleting aged data.",
|
||||||
route: "settings.contract.edit",
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Nastavitve arhiviranja",
|
|
||||||
description: "Določite pravila za arhiviranje ali mehko brisanje starih podatkov.",
|
|
||||||
route: "settings.archive.index",
|
route: "settings.archive.index",
|
||||||
icon: Archive,
|
icon: Archive,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Poročila",
|
title: "Reports",
|
||||||
description:
|
description: "Configure database-driven reports with dynamic queries.",
|
||||||
"Konfiguracija poročil na podlagi podatkovne baze z dinamičnimi poizvedbami.",
|
|
||||||
route: "settings.reports.index",
|
route: "settings.reports.index",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
},
|
},
|
||||||
|
|
@ -81,14 +61,14 @@ const settingsCards = [
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="Nastavitve">
|
<AppLayout title="Settings">
|
||||||
<template #header />
|
<template #header />
|
||||||
<div class="pt-12">
|
<div class="pt-12">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-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">
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
Upravljanje konfiguracije in nastavitev aplikacije
|
Manage your application configuration and preferences
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -112,7 +92,7 @@ const settingsCards = [
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Link :href="route(card.route)">
|
<Link :href="route(card.route)">
|
||||||
<Button class="w-full group">
|
<Button class="w-full group">
|
||||||
Odpri nastavitve
|
Open Settings
|
||||||
<ArrowRight
|
<ArrowRight
|
||||||
class="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1"
|
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: () => [] },
|
email_templates: { type: Array, default: () => [] },
|
||||||
events: { type: Array, default: () => [] },
|
events: { type: Array, default: () => [] },
|
||||||
archive_settings: { type: Array, default: () => [] },
|
archive_settings: { type: Array, default: () => [] },
|
||||||
condition_fields: { type: Array, default: () => [] },
|
|
||||||
condition_operators: { type: Object, default: () => ({}) },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeTab = ref("actions");
|
const activeTab = ref("actions");
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="Potek dela">
|
<AppLayout title="Workflow">
|
||||||
<template #header></template>
|
<template #header></template>
|
||||||
<div class="pt-12">
|
<div class="pt-12">
|
||||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
|
|
@ -36,7 +34,7 @@ const activeTab = ref("actions");
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Workflow :size="18" />
|
<Workflow :size="18" />
|
||||||
<CardTitle class="uppercase">Potek dela</CardTitle>
|
<CardTitle class="uppercase">Workflow</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<Tabs v-model="activeTab" class="border-t">
|
<Tabs v-model="activeTab" class="border-t">
|
||||||
|
|
@ -59,8 +57,6 @@ const activeTab = ref("actions");
|
||||||
:available-events="events"
|
:available-events="events"
|
||||||
:segments="segments"
|
:segments="segments"
|
||||||
:archive-settings="archive_settings"
|
:archive-settings="archive_settings"
|
||||||
:condition-fields="condition_fields"
|
|
||||||
:condition-operators="condition_operators"
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
|
// flowbite-vue table imports removed; using DataTableClient
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -26,7 +27,7 @@ import { Input } from "@/Components/ui/input";
|
||||||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||||
import { Button } from "@/Components/ui/button";
|
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 InlineColorPicker from "@/Components/InlineColorPicker.vue";
|
||||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||||
import { FilterIcon, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
|
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 = [
|
const columns = [
|
||||||
{ key: "id", label: "#", sortable: true, class: "w-16" },
|
{ key: "id", label: "#", sortable: true, class: "w-16" },
|
||||||
{ key: "name", label: "Ime", sortable: true },
|
{ key: "name", label: "Ime", sortable: true },
|
||||||
{ key: "color_tag", label: "Barva", sortable: false },
|
{ key: "color_tag", label: "Barva", sortable: false },
|
||||||
{ key: "segment", label: "Segment", sortable: false },
|
{ key: "segment", label: "Segment", sortable: false },
|
||||||
{ key: "decisions", label: "Odločitve", sortable: false, class: "w-32" },
|
{ key: "decisions", label: "Odločitve", sortable: false, class: "w-32" },
|
||||||
{ key: "actions", label: "", sortable: false, class: "w-12" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
|
|
@ -227,12 +231,18 @@ const destroyAction = () => {
|
||||||
<Button @click="openCreateDrawer">+ Dodaj akcijo</Button>
|
<Button @click="openCreateDrawer">+ Dodaj akcijo</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<DataTableNew2
|
<DataTableClient
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="filtered"
|
:rows="filtered"
|
||||||
:pageSize="25"
|
:sort="sort"
|
||||||
|
:search="''"
|
||||||
|
:page="page"
|
||||||
|
:pageSize="pageSize"
|
||||||
:showToolbar="false"
|
:showToolbar="false"
|
||||||
:showPagination="true"
|
:showPagination="true"
|
||||||
|
@update:sort="(v) => (sort = v)"
|
||||||
|
@update:page="(v) => (page = v)"
|
||||||
|
@update:pageSize="(v) => (pageSize = v)"
|
||||||
>
|
>
|
||||||
<template #cell-color_tag="{ row }">
|
<template #cell-color_tag="{ row }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -252,7 +262,7 @@ const destroyAction = () => {
|
||||||
{{ row.segment?.name || "" }}
|
{{ row.segment?.name || "" }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
|
|
@ -275,7 +285,7 @@ const destroyAction = () => {
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
</DataTableNew2>
|
</DataTableClient>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog v-model:open="drawerEdit">
|
<Dialog v-model:open="drawerEdit">
|
||||||
|
|
@ -295,7 +305,7 @@ const destroyAction = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div>
|
||||||
<InputLabel for="segmentEdit">Segment</InputLabel>
|
<InputLabel for="segmentEdit">Segment</InputLabel>
|
||||||
<AppCombobox
|
<AppCombobox
|
||||||
id="segmentEdit"
|
id="segmentEdit"
|
||||||
|
|
@ -313,7 +323,7 @@ const destroyAction = () => {
|
||||||
v-model="form.decisions"
|
v-model="form.decisions"
|
||||||
:items="selectOptions"
|
:items="selectOptions"
|
||||||
placeholder="Dodaj odločitev"
|
placeholder="Dodaj odločitev"
|
||||||
chip-variant="secondary"
|
content-class="p-0 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -363,7 +373,7 @@ const destroyAction = () => {
|
||||||
v-model="createForm.decisions"
|
v-model="createForm.decisions"
|
||||||
:items="selectOptions"
|
:items="selectOptions"
|
||||||
placeholder="Dodaj odločitev"
|
placeholder="Dodaj odločitev"
|
||||||
chip-variant="secondary"
|
content-class="p-0 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { DottedMenu } from "@/Utilities/Icons";
|
// flowbite-vue table imports removed; using DataTableClient
|
||||||
|
import { EditIcon, TrashBinIcon, DottedMenu } from "@/Utilities/Icons";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -29,20 +30,11 @@ import {
|
||||||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||||
import { Button } from "@/Components/ui/button";
|
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 InlineColorPicker from "@/Components/InlineColorPicker.vue";
|
||||||
import Dropdown from "@/Components/Dropdown.vue";
|
import Dropdown from "@/Components/Dropdown.vue";
|
||||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||||
import {
|
import { FilterIcon, Trash2, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
|
||||||
FilterIcon,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Trash,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-vue-next";
|
|
||||||
import { Switch } from "@/Components/ui/switch";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -57,8 +49,6 @@ const props = defineProps({
|
||||||
availableEvents: { type: Array, default: () => [] },
|
availableEvents: { type: Array, default: () => [] },
|
||||||
segments: { type: Array, default: () => [] },
|
segments: { type: Array, default: () => [] },
|
||||||
archiveSettings: { type: Array, default: () => [] },
|
archiveSettings: { type: Array, default: () => [] },
|
||||||
conditionFields: { type: Array, default: () => [] },
|
|
||||||
conditionOperators: { type: Object, default: () => ({}) },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const drawerEdit = ref(false);
|
const drawerEdit = ref(false);
|
||||||
|
|
@ -74,6 +64,10 @@ const selectedEvents = ref([]);
|
||||||
|
|
||||||
const actionOptions = ref([]);
|
const actionOptions = ref([]);
|
||||||
|
|
||||||
|
// DataTable state
|
||||||
|
const sort = ref({ key: null, direction: null });
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(25);
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: "id", label: "#", sortable: true },
|
{ key: "id", label: "#", sortable: true },
|
||||||
{ key: "name", label: "Ime", sortable: true },
|
{ key: "name", label: "Ime", sortable: true },
|
||||||
|
|
@ -81,7 +75,6 @@ const columns = [
|
||||||
{ key: "events", label: "Dogodki", sortable: false },
|
{ key: "events", label: "Dogodki", sortable: false },
|
||||||
{ key: "belongs", label: "Pripada akcijam", sortable: false },
|
{ key: "belongs", label: "Pripada akcijam", sortable: false },
|
||||||
{ key: "auto_mail", label: "Auto mail", sortable: false },
|
{ key: "auto_mail", label: "Auto mail", sortable: false },
|
||||||
{ key: "actions", label: "", sortable: false, class: "w-12" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
|
|
@ -198,8 +191,6 @@ function defaultConfigForKey(key) {
|
||||||
return { archive_setting_id: null, reactivate: false };
|
return { archive_setting_id: null, reactivate: false };
|
||||||
case "end_field_job":
|
case "end_field_job":
|
||||||
return {};
|
return {};
|
||||||
case "add_call_later":
|
|
||||||
return {};
|
|
||||||
default:
|
default:
|
||||||
return {};
|
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) {
|
function tryAdoptRaw(ev) {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(ev.__rawJson || "{}");
|
const obj = JSON.parse(ev.__rawJson || "{}");
|
||||||
|
|
@ -508,12 +466,18 @@ const destroyDecision = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<DataTableNew2
|
<DataTableClient
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="filtered"
|
:rows="filtered"
|
||||||
:pageSize="25"
|
:sort="sort"
|
||||||
|
:search="''"
|
||||||
|
:page="page"
|
||||||
|
:pageSize="pageSize"
|
||||||
:showToolbar="false"
|
:showToolbar="false"
|
||||||
:showPagination="true"
|
:showPagination="true"
|
||||||
|
@update:sort="(v) => (sort = v)"
|
||||||
|
@update:page="(v) => (page = v)"
|
||||||
|
@update:pageSize="(v) => (pageSize = v)"
|
||||||
>
|
>
|
||||||
<template #cell-color_tag="{ row }">
|
<template #cell-color_tag="{ row }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -530,13 +494,14 @@ const destroyDecision = () => {
|
||||||
</template>
|
</template>
|
||||||
<template #cell-events="{ row }">
|
<template #cell-events="{ row }">
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<Dropdown align="left" width="64" :close-on-content-click="false">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200"
|
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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
|
@ -576,7 +541,7 @@ const destroyDecision = () => {
|
||||||
<template #cell-auto_mail="{ row }">
|
<template #cell-auto_mail="{ row }">
|
||||||
<div class="flex flex-col text-sm">
|
<div class="flex flex-col text-sm">
|
||||||
<span :class="row.auto_mail ? 'text-green-700' : 'text-gray-500'">{{
|
<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>
|
||||||
<span v-if="row.auto_mail && row.email_template_id" class="text-gray-600">
|
<span v-if="row.auto_mail && row.email_template_id" class="text-gray-600">
|
||||||
Template:
|
Template:
|
||||||
|
|
@ -584,7 +549,7 @@ const destroyDecision = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
|
|
@ -607,12 +572,12 @@ const destroyDecision = () => {
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
</DataTableNew2>
|
</DataTableClient>
|
||||||
</div>
|
</div>
|
||||||
<Dialog v-model:open="drawerEdit">
|
<Dialog v-model:open="drawerEdit">
|
||||||
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Uredi odločitev</DialogTitle>
|
<DialogTitle>Spremeni odločitev</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -703,16 +668,9 @@ const destroyDecision = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 self-end">
|
<div class="flex items-center gap-2 self-end">
|
||||||
<label
|
<label class="flex items-center gap-2 text-sm">
|
||||||
class="flex items-center gap-2 text-sm cursor-pointer select-none"
|
<Checkbox v-model="ev.active" />
|
||||||
>
|
Aktivno
|
||||||
<Switch v-model="ev.active" />
|
|
||||||
<span
|
|
||||||
:class="
|
|
||||||
ev.active ? 'text-green-700 font-medium' : 'text-muted-foreground'
|
|
||||||
"
|
|
||||||
>{{ ev.active ? "Aktivno" : "Neaktivno" }}</span
|
|
||||||
>
|
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -758,7 +716,7 @@ const destroyDecision = () => {
|
||||||
<template v-else-if="eventKey(ev) === 'archive_contract'">
|
<template v-else-if="eventKey(ev) === 'archive_contract'">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<InputLabel :for="`as-${idx}`" value="Nastavitev arhiva" />
|
<InputLabel :for="`as-${idx}`" value="Archive setting" />
|
||||||
<Select v-model="ev.config.archive_setting_id">
|
<Select v-model="ev.config.archive_setting_id">
|
||||||
<SelectTrigger :id="`as-${idx}`" class="w-full">
|
<SelectTrigger :id="`as-${idx}`" class="w-full">
|
||||||
<SelectValue placeholder="— Izberi nastavitev —" />
|
<SelectValue placeholder="— Izberi nastavitev —" />
|
||||||
|
|
@ -782,14 +740,9 @@ const destroyDecision = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end">
|
<div class="flex items-end">
|
||||||
<label
|
<label class="flex items-center gap-2 text-sm mt-6">
|
||||||
class="flex items-center gap-2 text-sm mt-6 cursor-pointer select-none"
|
<Checkbox v-model:checked="ev.config.reactivate" />
|
||||||
>
|
Reactivate namesto arhiva
|
||||||
<Switch
|
|
||||||
:model-value="ev.config.reactivate"
|
|
||||||
v-model:checked="ev.config.reactivate"
|
|
||||||
/>
|
|
||||||
Reaktiviraj namesto arhiviranja
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -799,13 +752,8 @@ const destroyDecision = () => {
|
||||||
Ta dogodek nima dodatnih nastavitev.
|
Ta dogodek nima dodatnih nastavitev.
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</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>
|
<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)" />
|
<InputLabel :for="`cfg-${idx}`" value="Napredna nastavitev (JSON)" />
|
||||||
<textarea
|
<textarea
|
||||||
:id="`cfg-${idx}`"
|
:id="`cfg-${idx}`"
|
||||||
|
|
@ -821,104 +769,6 @@ const destroyDecision = () => {
|
||||||
></textarea>
|
></textarea>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -936,7 +786,7 @@ const destroyDecision = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="closeEditDrawer">Prekliči</Button>
|
<Button variant="outline" @click="closeEditDrawer">Cancel</Button>
|
||||||
<Button @click="update" :disabled="form.processing || !eventsValidEdit"
|
<Button @click="update" :disabled="form.processing || !eventsValidEdit"
|
||||||
>Shrani</Button
|
>Shrani</Button
|
||||||
>
|
>
|
||||||
|
|
@ -1045,16 +895,9 @@ const destroyDecision = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 self-end">
|
<div class="flex items-center gap-2 self-end">
|
||||||
<label
|
<label class="flex items-center gap-2 text-sm">
|
||||||
class="flex items-center gap-2 text-sm cursor-pointer select-none"
|
<Checkbox v-model:checked="ev.active" />
|
||||||
>
|
Aktivno
|
||||||
<Switch v-model="ev.active" />
|
|
||||||
<span
|
|
||||||
:class="
|
|
||||||
ev.active ? 'text-green-700 font-medium' : 'text-muted-foreground'
|
|
||||||
"
|
|
||||||
>{{ ev.active ? "Aktivno" : "Neaktivno" }}</span
|
|
||||||
>
|
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -1100,7 +943,7 @@ const destroyDecision = () => {
|
||||||
<template v-else-if="eventKey(ev) === 'archive_contract'">
|
<template v-else-if="eventKey(ev) === 'archive_contract'">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<InputLabel :for="`cas-${idx}`" value="Nastavitev arhiva" />
|
<InputLabel :for="`cas-${idx}`" value="Archive setting" />
|
||||||
<Select v-model="ev.config.archive_setting_id">
|
<Select v-model="ev.config.archive_setting_id">
|
||||||
<SelectTrigger :id="`cas-${idx}`" class="w-full">
|
<SelectTrigger :id="`cas-${idx}`" class="w-full">
|
||||||
<SelectValue placeholder="— Izberi nastavitev —" />
|
<SelectValue placeholder="— Izberi nastavitev —" />
|
||||||
|
|
@ -1126,14 +969,9 @@ const destroyDecision = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end">
|
<div class="flex items-end">
|
||||||
<label
|
<label class="flex items-center gap-2 text-sm mt-6">
|
||||||
class="flex items-center gap-2 text-sm mt-6 cursor-pointer select-none"
|
<Checkbox v-model:checked="ev.config.reactivate" />
|
||||||
>
|
Reactivate namesto arhiva
|
||||||
<Switch
|
|
||||||
:model-value="ev.config.reactivate"
|
|
||||||
v-model:checked="ev.config.reactivate"
|
|
||||||
/>
|
|
||||||
Reaktiviraj namesto arhiviranja
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1143,11 +981,6 @@ const destroyDecision = () => {
|
||||||
Ta dogodek nima dodatnih nastavitev.
|
Ta dogodek nima dodatnih nastavitev.
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</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>
|
<template v-else>
|
||||||
<InputLabel :for="`ccfg-${idx}`" value="Napredna nastavitev (JSON)" />
|
<InputLabel :for="`ccfg-${idx}`" value="Napredna nastavitev (JSON)" />
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -1164,104 +997,6 @@ const destroyDecision = () => {
|
||||||
></textarea>
|
></textarea>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1279,7 +1014,7 @@ const destroyDecision = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="closeCreateDrawer">Prekliči</Button>
|
<Button variant="outline" @click="closeCreateDrawer">Cancel</Button>
|
||||||
<Button @click="store" :disabled="createForm.processing || !eventsValidCreate"
|
<Button @click="store" :disabled="createForm.processing || !eventsValidCreate"
|
||||||
>Dodaj</Button
|
>Dodaj</Button
|
||||||
>
|
>
|
||||||
|
|
@ -1290,15 +1025,15 @@ const destroyDecision = () => {
|
||||||
<AlertDialog v-model:open="showDelete">
|
<AlertDialog v-model:open="showDelete">
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Zbriši odločitev</AlertDialogTitle>
|
<AlertDialogTitle>Delete decision</AlertDialogTitle>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
Ali ste prepričani, da želite zbrisati odločitev "{{ toDelete?.name }}"? Tega
|
Are you sure you want to delete decision "{{ toDelete?.name }}"? This cannot be
|
||||||
dejanja ni mogoče razveljaviti.
|
undone.
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<Button variant="outline" @click="cancelDelete">Prekliči</Button>
|
<Button variant="outline" @click="cancelDelete">Cancel</Button>
|
||||||
<Button variant="destructive" @click="destroyDecision">Zbriši</Button>
|
<Button variant="destructive" @click="destroyDecision">Delete</Button>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export const fmtDateTime = (d) => {
|
export function fmtDateTime(d) {
|
||||||
if (!d) return "";
|
if (!d) return "";
|
||||||
try {
|
try {
|
||||||
const dt = new Date(d);
|
const dt = new Date(d);
|
||||||
|
|
@ -33,12 +33,8 @@ export function fmtDateDMY(value) {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
if (isNaN(d)) return "-";
|
if (isNaN(d)) return "-";
|
||||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
timeZone: "Europe/Ljubljana",
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
day: "2-digit",
|
const yyyy = d.getFullYear();
|
||||||
month: "2-digit",
|
return `${dd}.${mm}.${yyyy}`;
|
||||||
year: "numeric",
|
|
||||||
}).formatToParts(d);
|
|
||||||
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
||||||
return `${map.day}.${map.month}.${map.year}`;
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AccountBookingController;
|
use App\Http\Controllers\AccountBookingController;
|
||||||
use App\Http\Controllers\AccountInstallmentController;
|
|
||||||
use App\Http\Controllers\AccountPaymentController;
|
use App\Http\Controllers\AccountPaymentController;
|
||||||
use App\Http\Controllers\ActivityNotificationController;
|
use App\Http\Controllers\ActivityNotificationController;
|
||||||
use App\Http\Controllers\ArchiveSettingController;
|
use App\Http\Controllers\ArchiveSettingController;
|
||||||
|
|
@ -9,12 +8,10 @@
|
||||||
use App\Http\Controllers\ClientCaseContoller;
|
use App\Http\Controllers\ClientCaseContoller;
|
||||||
use App\Http\Controllers\ClientController;
|
use App\Http\Controllers\ClientController;
|
||||||
use App\Http\Controllers\ContractConfigController;
|
use App\Http\Controllers\ContractConfigController;
|
||||||
use App\Http\Controllers\ContractSettingController;
|
|
||||||
use App\Http\Controllers\FieldJobController;
|
use App\Http\Controllers\FieldJobController;
|
||||||
use App\Http\Controllers\FieldJobSettingController;
|
use App\Http\Controllers\FieldJobSettingController;
|
||||||
use App\Http\Controllers\ImportController;
|
use App\Http\Controllers\ImportController;
|
||||||
use App\Http\Controllers\ImportTemplateController;
|
use App\Http\Controllers\ImportTemplateController;
|
||||||
use App\Http\Controllers\InstallmentSettingController;
|
|
||||||
use App\Http\Controllers\NotificationController;
|
use App\Http\Controllers\NotificationController;
|
||||||
use App\Http\Controllers\PaymentSettingController;
|
use App\Http\Controllers\PaymentSettingController;
|
||||||
use App\Http\Controllers\PersonController;
|
use App\Http\Controllers\PersonController;
|
||||||
|
|
@ -86,7 +83,6 @@
|
||||||
Route::post('users', [\App\Http\Controllers\Admin\UserRoleController::class, 'store'])->name('users.store');
|
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::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}/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
|
// Permissions management
|
||||||
Route::get('permissions', [\App\Http\Controllers\Admin\PermissionController::class, 'index'])->name('permissions.index');
|
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', [\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');
|
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
|
// 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::get('bookings', [AccountBookingController::class, 'index'])->name('bookings.index');
|
||||||
Route::post('bookings', [AccountBookingController::class, 'store'])->name('bookings.store');
|
Route::post('bookings', [AccountBookingController::class, 'store'])->name('bookings.store');
|
||||||
Route::delete('bookings/{booking}', [AccountBookingController::class, 'destroy'])->name('bookings.destroy');
|
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
|
// settings - payment settings
|
||||||
Route::get('settings/payment', [PaymentSettingController::class, 'edit'])->name('settings.payment.edit');
|
Route::get('settings/payment', [PaymentSettingController::class, 'edit'])->name('settings.payment.edit');
|
||||||
Route::put('settings/payment', [PaymentSettingController::class, 'update'])->name('settings.payment.update');
|
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) {
|
Route::get('types/address', function (Request $request) {
|
||||||
$types = App\Models\Person\AddressType::all();
|
$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');
|
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]);
|
$contract3->segments()->attach($segment->id, ['active' => true]);
|
||||||
|
|
||||||
// Test without date filters - should return all contracts
|
// 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,
|
'segment_id' => $segment->id,
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
expect($data)->toHaveCount(3);
|
expect($data)->toHaveCount(3);
|
||||||
|
|
||||||
// Test with start_date_from filter
|
// Test with start_date_from filter
|
||||||
$response = $this->getJson(route('packages.contracts', [
|
$response = $this->getJson(route('admin.packages.contracts', [
|
||||||
'segment_id' => $segment->id,
|
'segment_id' => $segment->id,
|
||||||
'start_date_from' => '2024-02-01',
|
'start_date_from' => '2024-02-01',
|
||||||
]));
|
]));
|
||||||
|
|
@ -92,7 +92,7 @@
|
||||||
expect(collect($data)->pluck('reference'))->toContain('CONTRACT-2024-002', 'CONTRACT-2024-003');
|
expect(collect($data)->pluck('reference'))->toContain('CONTRACT-2024-002', 'CONTRACT-2024-003');
|
||||||
|
|
||||||
// Test with start_date_to filter
|
// Test with start_date_to filter
|
||||||
$response = $this->getJson(route('packages.contracts', [
|
$response = $this->getJson(route('admin.packages.contracts', [
|
||||||
'segment_id' => $segment->id,
|
'segment_id' => $segment->id,
|
||||||
'start_date_to' => '2024-03-31',
|
'start_date_to' => '2024-03-31',
|
||||||
]));
|
]));
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
expect(collect($data)->pluck('reference'))->toContain('CONTRACT-2024-001', 'CONTRACT-2024-002');
|
expect(collect($data)->pluck('reference'))->toContain('CONTRACT-2024-001', 'CONTRACT-2024-002');
|
||||||
|
|
||||||
// Test with both date filters
|
// Test with both date filters
|
||||||
$response = $this->getJson(route('packages.contracts', [
|
$response = $this->getJson(route('admin.packages.contracts', [
|
||||||
'segment_id' => $segment->id,
|
'segment_id' => $segment->id,
|
||||||
'start_date_from' => '2024-02-01',
|
'start_date_from' => '2024-02-01',
|
||||||
'start_date_to' => '2024-04-30',
|
'start_date_to' => '2024-04-30',
|
||||||
|
|
@ -133,7 +133,7 @@
|
||||||
$segment = Segment::factory()->create(['active' => true]);
|
$segment = Segment::factory()->create(['active' => true]);
|
||||||
|
|
||||||
// Test invalid start_date_from
|
// Test invalid start_date_from
|
||||||
$response = $this->getJson(route('packages.contracts', [
|
$response = $this->getJson(route('admin.packages.contracts', [
|
||||||
'segment_id' => $segment->id,
|
'segment_id' => $segment->id,
|
||||||
'start_date_from' => 'invalid-date',
|
'start_date_from' => 'invalid-date',
|
||||||
]));
|
]));
|
||||||
|
|
@ -142,7 +142,7 @@
|
||||||
$response->assertJsonValidationErrors('start_date_from');
|
$response->assertJsonValidationErrors('start_date_from');
|
||||||
|
|
||||||
// Test invalid start_date_to
|
// Test invalid start_date_to
|
||||||
$response = $this->getJson(route('packages.contracts', [
|
$response = $this->getJson(route('admin.packages.contracts', [
|
||||||
'segment_id' => $segment->id,
|
'segment_id' => $segment->id,
|
||||||
'start_date_to' => 'invalid-date',
|
'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;
|
use Tests\TestCase;
|
||||||
|
|
||||||
uses(TestCase::class)->in('Feature', 'Unit');
|
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