Compare commits
No commits in common. "master" and "production" have entirely different histories.
master
...
production
|
|
@ -1,132 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreInstallmentRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Booking;
|
||||
use App\Models\Installment;
|
||||
use App\Models\InstallmentSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class AccountInstallmentController extends Controller
|
||||
{
|
||||
public function list(Account $account): JsonResponse
|
||||
{
|
||||
$installments = Installment::query()
|
||||
->where('account_id', $account->id)
|
||||
->orderByDesc('installment_at')
|
||||
->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'installment_at', 'created_at'])
|
||||
->map(function (Installment $i) {
|
||||
return [
|
||||
'id' => $i->id,
|
||||
'amount' => (float) $i->amount,
|
||||
'balance_before' => (float) ($i->balance_before ?? 0),
|
||||
'currency' => $i->currency,
|
||||
'reference' => $i->reference,
|
||||
'installment_at' => optional($i->installment_at)?->toDateString(),
|
||||
'created_at' => optional($i->created_at)?->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'account' => [
|
||||
'id' => $account->id,
|
||||
'balance_amount' => $account->balance_amount,
|
||||
],
|
||||
'installments' => $installments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreInstallmentRequest $request, Account $account): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$amountCents = (int) round(((float) $validated['amount']) * 100);
|
||||
|
||||
$settings = InstallmentSetting::query()->first();
|
||||
$defaultCurrency = strtoupper($settings->default_currency ?? 'EUR');
|
||||
|
||||
$installment = Installment::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_before' => (float) ($account->balance_amount ?? 0),
|
||||
'amount' => (float) $validated['amount'],
|
||||
'currency' => strtoupper($validated['currency'] ?? $defaultCurrency),
|
||||
'reference' => $validated['reference'] ?? null,
|
||||
'installment_at' => $validated['installment_at'] ?? now(),
|
||||
'meta' => $validated['meta'] ?? null,
|
||||
'created_by' => $request->user()?->id,
|
||||
]);
|
||||
|
||||
// Debit booking — increases the account balance
|
||||
Booking::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'payment_id' => null,
|
||||
'amount_cents' => $amountCents,
|
||||
'type' => 'debit',
|
||||
'description' => $installment->reference ? ('Obremenitev '.$installment->reference) : 'Obremenitev',
|
||||
'booked_at' => $installment->installment_at ?? now(),
|
||||
]);
|
||||
|
||||
if ($settings && ($settings->create_activity_on_installment ?? false)) {
|
||||
$note = $settings->activity_note_template ?? 'Dodan obrok';
|
||||
$note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $installment->currency], $note);
|
||||
|
||||
$account->refresh();
|
||||
$beforeStr = number_format((float) ($installment->balance_before ?? 0), 2, ',', '.').' '.$installment->currency;
|
||||
$afterStr = number_format((float) ($account->balance_amount ?? 0), 2, ',', '.').' '.$installment->currency;
|
||||
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: obrok)";
|
||||
|
||||
$account->loadMissing('contract');
|
||||
$clientCaseId = $account->contract?->client_case_id;
|
||||
if ($clientCaseId) {
|
||||
$activity = Activity::query()->create([
|
||||
'due_date' => null,
|
||||
'amount' => $amountCents / 100,
|
||||
'note' => $note,
|
||||
'action_id' => $settings->default_action_id,
|
||||
'decision_id' => $settings->default_decision_id,
|
||||
'client_case_id' => $clientCaseId,
|
||||
'contract_id' => $account->contract_id,
|
||||
]);
|
||||
$installment->update(['activity_id' => $activity->id]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Installment created.');
|
||||
}
|
||||
|
||||
public function destroy(Account $account, Installment $installment): RedirectResponse|JsonResponse
|
||||
{
|
||||
if ($installment->account_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Delete related debit booking(s) to revert balance via model events
|
||||
Booking::query()
|
||||
->where('account_id', $account->id)
|
||||
->where('type', 'debit')
|
||||
->whereDate('booked_at', optional($installment->installment_at)?->toDateString())
|
||||
->where('amount_cents', (int) round(((float) $installment->amount) * 100))
|
||||
->whereNull('payment_id')
|
||||
->get()
|
||||
->each->delete();
|
||||
|
||||
if ($installment->activity_id) {
|
||||
$activity = Activity::query()->find($installment->activity_id);
|
||||
if ($activity) {
|
||||
$activity->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$installment->delete();
|
||||
|
||||
if (request()->wantsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Installment deleted.');
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
use App\Models\SmsTemplate;
|
||||
use App\Services\Contact\PhoneSelector;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
|
@ -29,7 +30,7 @@ public function index(Request $request): Response
|
|||
->latest('id')
|
||||
->paginate($perPage);
|
||||
|
||||
return Inertia::render('Packages/Index', [
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
]);
|
||||
}
|
||||
|
|
@ -69,7 +70,7 @@ public function create(Request $request): Response
|
|||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Packages/Create', [
|
||||
return Inertia::render('Admin/Packages/Create', [
|
||||
'profiles' => $profiles,
|
||||
'senders' => $senders,
|
||||
'templates' => $templates,
|
||||
|
|
@ -212,7 +213,7 @@ public function show(Package $package, SmsService $sms): Response
|
|||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Packages/Show', [
|
||||
return Inertia::render('Admin/Packages/Show', [
|
||||
'package' => $package,
|
||||
'items' => $items,
|
||||
'preview' => $preview,
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -306,7 +306,6 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
try {
|
||||
$attributes = $request->validate([
|
||||
'due_date' => 'nullable|date',
|
||||
'call_back_at' => 'nullable|date_format:Y-m-d H:i:s|after_or_equal:now',
|
||||
'amount' => 'nullable|decimal:0,4',
|
||||
'note' => 'nullable|string',
|
||||
'action_id' => 'exists:\App\Models\Action,id',
|
||||
|
|
@ -372,7 +371,6 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'call_back_at' => $attributes['call_back_at'] ?? null,
|
||||
'amount' => $attributes['amount'] ?? null,
|
||||
'note' => $attributes['note'] ?? null,
|
||||
'action_id' => $attributes['action_id'],
|
||||
|
|
@ -827,8 +825,9 @@ public function show(ClientCase $clientCase)
|
|||
}
|
||||
|
||||
// Get contracts using service
|
||||
$contracts = $this->caseDataService->getContracts($case, $segmentId);
|
||||
$contractIds = collect($contracts)->pluck('id')->all();
|
||||
$contractsPerPage = request()->integer('contracts_per_page', 10);
|
||||
$contracts = $this->caseDataService->getContracts($case, $segmentId, $contractsPerPage);
|
||||
$contractIds = collect($contracts->items())->pluck('id')->all();
|
||||
|
||||
// Get activities using service
|
||||
$activitiesPerPage = request()->integer('activities_per_page', 15);
|
||||
|
|
@ -869,14 +868,11 @@ public function show(ClientCase $clientCase)
|
|||
'decisions.emailTemplate' => function ($q) {
|
||||
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
||||
},
|
||||
'decisions.events' => function ($q) {
|
||||
$q->select('events.id', 'events.key', 'events.name');
|
||||
},
|
||||
])
|
||||
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||
'types' => $types,
|
||||
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
|
||||
'all_segments' => Segment::query()->where('active', true)->get(['id', 'name']),
|
||||
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
|
||||
'current_segment' => $currentSegment,
|
||||
'sms_profiles' => \App\Models\SmsProfile::query()
|
||||
->select(['id', 'name', 'default_sender_id'])
|
||||
|
|
@ -893,7 +889,6 @@ public function show(ClientCase $clientCase)
|
|||
->select(['id', 'name', 'content', 'allow_custom_body'])
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -1107,7 +1102,6 @@ public function archiveBatch(Request $request)
|
|||
|
||||
if (! $setting) {
|
||||
\Log::warning('No archive settings found for batch archive');
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => 'No archive settings found',
|
||||
]);
|
||||
|
|
@ -1125,7 +1119,6 @@ public function archiveBatch(Request $request)
|
|||
// Skip if contract is already archived (active = 0)
|
||||
if (!$contract->active) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -1215,7 +1208,7 @@ public function archiveBatch(Request $request)
|
|||
if ($skippedCount > 0) {
|
||||
$message .= ", skipped $skippedCount already archived";
|
||||
}
|
||||
$message .= ', '.count($errors).' failed';
|
||||
$message .= ", " . count($errors) . " failed";
|
||||
|
||||
return back()->with('flash', [
|
||||
'error' => $message,
|
||||
|
|
@ -1352,10 +1345,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
|||
if (! empty($validated['sender_id'])) {
|
||||
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
|
||||
if (! $sender) {
|
||||
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
||||
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
||||
}
|
||||
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
|
||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||
}
|
||||
}
|
||||
if (! $profile) {
|
||||
|
|
@ -1398,7 +1391,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
|||
}
|
||||
|
||||
// Create an activity before sending
|
||||
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
||||
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
||||
$activityData = [
|
||||
'note' => $activityNote,
|
||||
'user_id' => optional($request->user())->id,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ public function show(Client $client, Request $request)
|
|||
|
||||
return Inertia::render('Client/Show', [
|
||||
'client' => $data,
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
'client_cases' => $data->clientCases()
|
||||
->select('client_cases.*')
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
|
|
@ -163,7 +162,6 @@ public function contracts(Client $client, Request $request)
|
|||
|
||||
return Inertia::render('Client/Contracts', [
|
||||
'client' => $data,
|
||||
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
|
||||
'contracts' => $contractsQuery
|
||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
||||
->withQueryString(),
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateInstallmentSettingRequest;
|
||||
use App\Models\Action;
|
||||
use App\Models\Decision;
|
||||
use App\Models\InstallmentSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class InstallmentSettingController extends Controller
|
||||
{
|
||||
public function edit(): Response
|
||||
{
|
||||
$setting = InstallmentSetting::query()->first();
|
||||
if (! $setting) {
|
||||
$setting = InstallmentSetting::query()->create([
|
||||
'default_currency' => 'EUR',
|
||||
'create_activity_on_installment' => false,
|
||||
'default_decision_id' => null,
|
||||
'default_action_id' => null,
|
||||
'activity_note_template' => 'Dodan obrok: {amount} {currency}',
|
||||
]);
|
||||
}
|
||||
|
||||
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||
$actions = Action::query()
|
||||
->with(['decisions:id'])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (Action $a) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'decision_ids' => $a->decisions->pluck('id')->values(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Settings/Installments/Index', [
|
||||
'setting' => [
|
||||
'id' => $setting->id,
|
||||
'default_currency' => $setting->default_currency,
|
||||
'create_activity_on_installment' => (bool) $setting->create_activity_on_installment,
|
||||
'default_decision_id' => $setting->default_decision_id,
|
||||
'default_action_id' => $setting->default_action_id,
|
||||
'activity_note_template' => $setting->activity_note_template,
|
||||
],
|
||||
'decisions' => $decisions,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateInstallmentSettingRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$setting = InstallmentSetting::query()->firstOrFail();
|
||||
|
||||
$data['create_activity_on_installment'] = (bool) ($data['create_activity_on_installment'] ?? false);
|
||||
|
||||
$setting->update($data);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene.');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BankAccount;
|
||||
use App\Models\Person\Person;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
|
@ -27,6 +28,8 @@ public function update(Person $person, Request $request)
|
|||
|
||||
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function createAddress(Person $person, Request $request)
|
||||
|
|
@ -77,6 +80,7 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
|||
$address = $person->addresses()->findOrFail($address_id);
|
||||
$address->delete(); // soft delete
|
||||
|
||||
|
||||
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
||||
}
|
||||
|
||||
|
|
@ -138,14 +142,8 @@ public function createEmail(Person $person, Request $request)
|
|||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||
unset($attributes['decision_ids']);
|
||||
$attributes['preferences'] = array_merge($attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||
|
||||
// Dedup: avoid duplicate email per person by value
|
||||
$email = $person->emails()->firstOrCreate([
|
||||
'value' => $attributes['value'],
|
||||
|
|
@ -166,16 +164,10 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
|||
'verified_at' => 'nullable|date',
|
||||
'preferences' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'decision_ids' => 'nullable|array',
|
||||
'decision_ids.*' => 'integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$email = $person->emails()->findOrFail($email_id);
|
||||
|
||||
$decisionIds = array_map('intval', $attributes['decision_ids'] ?? []);
|
||||
unset($attributes['decision_ids']);
|
||||
$attributes['preferences'] = array_merge($email->preferences ?? [], $attributes['preferences'] ?? [], ['decision_ids' => $decisionIds]);
|
||||
|
||||
$email->update($attributes);
|
||||
|
||||
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
||||
|
|
@ -212,8 +204,10 @@ public function createTrr(Person $person, Request $request)
|
|||
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||
$trr = $person->bankAccounts()->create($attributes);
|
||||
|
||||
|
||||
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function updateTrr(Person $person, int $trr_id, Request $request)
|
||||
|
|
@ -244,6 +238,7 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
|||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->delete();
|
||||
|
||||
|
||||
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,15 +59,6 @@ public function share(Request $request): array
|
|||
'info' => fn () => $request->session()->get('info'),
|
||||
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
||||
],
|
||||
'callLaterCount' => function () use ($request) {
|
||||
if (! $request->user()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return \App\Models\CallLater::query()
|
||||
->whereNull('completed_at')
|
||||
->count();
|
||||
},
|
||||
'notifications' => function () use ($request) {
|
||||
try {
|
||||
$user = $request->user();
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInstallmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'numeric', 'min:0.01'],
|
||||
'currency' => ['nullable', 'string', 'size:3'],
|
||||
'reference' => ['nullable', 'string', 'max:100'],
|
||||
'installment_at' => ['nullable', 'date'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -10,10 +10,9 @@
|
|||
|
||||
class Account extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||
use SoftDeletes;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'reference',
|
||||
|
|
@ -59,11 +58,6 @@ public function payments(): HasMany
|
|||
return $this->hasMany(\App\Models\Payment::class);
|
||||
}
|
||||
|
||||
public function installments(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Installment::class);
|
||||
}
|
||||
|
||||
public function bookings(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Booking::class);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ class Activity extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'due_date',
|
||||
'call_back_at',
|
||||
'amount',
|
||||
'note',
|
||||
'action_id',
|
||||
|
|
@ -28,13 +27,6 @@ class Activity extends Model
|
|||
'client_case_id',
|
||||
];
|
||||
|
||||
/*protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'call_back_at' => 'datetime',
|
||||
];
|
||||
}*/
|
||||
|
||||
protected $hidden = [
|
||||
'action_id',
|
||||
'decision_id',
|
||||
|
|
@ -154,9 +146,4 @@ public function user(): BelongsTo
|
|||
{
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
|
||||
public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\CallLater::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CallLater extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'activity_id',
|
||||
'client_case_id',
|
||||
'contract_id',
|
||||
'user_id',
|
||||
'call_back_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'call_back_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function activity(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Activity::class);
|
||||
}
|
||||
|
||||
public function clientCase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCase::class);
|
||||
}
|
||||
|
||||
public function contract(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contract::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,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',
|
||||
];
|
||||
}
|
||||
|
|
@ -59,23 +59,10 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
|||
// Resolve eligible recipients: client's person emails with receive_auto_mails = true
|
||||
$recipients = [];
|
||||
if ($client && $client->person) {
|
||||
$emails = Email::query()
|
||||
$recipients = Email::query()
|
||||
->where('person_id', $client->person->id)
|
||||
->where('is_active', true)
|
||||
->where('receive_auto_mails', true)
|
||||
->get(['value', 'preferences']);
|
||||
|
||||
$recipients = $emails
|
||||
->filter(function (Email $email) use ($decision): bool {
|
||||
$decisionIds = $email->preferences['decision_ids'] ?? [];
|
||||
|
||||
// Empty list means "all decisions" — always receive
|
||||
if (empty($decisionIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array((int) $decision->id, array_map('intval', $decisionIds), true);
|
||||
})
|
||||
->pluck('value')
|
||||
->map(fn ($v) => strtolower(trim((string) $v)))
|
||||
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@
|
|||
class ClientCaseDataService
|
||||
{
|
||||
/**
|
||||
* Get contracts for a client case with optional segment filtering.
|
||||
* Get paginated contracts for a client case with optional segment filtering.
|
||||
*/
|
||||
public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Collection
|
||||
public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int $perPage = 50): LengthAwarePaginator
|
||||
{
|
||||
$query = $clientCase->contracts()
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
|
||||
|
|
@ -40,8 +40,9 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Co
|
|||
$query->forSegment($segmentId);
|
||||
}
|
||||
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
return $query->get();
|
||||
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DecisionEvents\Handlers;
|
||||
|
||||
use App\Models\CallLater;
|
||||
use App\Services\DecisionEvents\Contracts\DecisionEventHandler;
|
||||
use App\Services\DecisionEvents\DecisionEventContext;
|
||||
|
||||
class CallLaterHandler implements DecisionEventHandler
|
||||
{
|
||||
public function handle(DecisionEventContext $context, array $config = []): void
|
||||
{
|
||||
$activity = $context->activity;
|
||||
|
||||
if (empty($activity->call_back_at)) {
|
||||
return;
|
||||
}
|
||||
|
||||
CallLater::create([
|
||||
'activity_id' => $activity->id,
|
||||
'client_case_id' => $activity->client_case_id,
|
||||
'contract_id' => $activity->contract_id,
|
||||
'user_id' => $activity->user_id,
|
||||
'call_back_at' => $activity->call_back_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,19 +17,15 @@ class Registry
|
|||
'add_segment' => AddSegmentHandler::class,
|
||||
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class,
|
||||
'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class,
|
||||
'add_call_later' => \App\Services\DecisionEvents\Handlers\CallLaterHandler::class,
|
||||
];
|
||||
|
||||
public static function resolve(string $key): DecisionEventHandler
|
||||
{
|
||||
$key = trim(strtolower($key));
|
||||
$class = static::$map[$key] ?? null;
|
||||
if (! $class) {
|
||||
if (! $class || ! class_exists($class)) {
|
||||
throw new InvalidArgumentException("Unknown decision event handler for key: {$key}");
|
||||
}
|
||||
if (! class_exists($class)) {
|
||||
throw new InvalidArgumentException("Handler class {$class} for key {$key} does not exist (check autoload)");
|
||||
}
|
||||
$handler = app($class);
|
||||
if (! $handler instanceof DecisionEventHandler) {
|
||||
throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler");
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -31,11 +31,6 @@ public function run(): void
|
|||
'name' => 'End field job',
|
||||
'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).',
|
||||
],
|
||||
[
|
||||
'key' => 'add_call_later',
|
||||
'name' => 'Klic kasneje',
|
||||
'description' => 'Ustvari zapis za povratni klic ob določenem datumu in uri.',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
|
|
|
|||
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -6029,6 +6029,24 @@
|
|||
"which": "bin/which"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
|
|
|||
|
|
@ -11,9 +11,6 @@
|
|||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Pure">
|
||||
<directory>tests/Pure</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from "@tanstack/vue-table";
|
||||
import { valueUpdater } from "@/lib/utils";
|
||||
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
|
||||
import DataTablePaginationClient from "./DataTablePaginationClient.vue";
|
||||
import DataTablePagination from "./DataTablePagination.vue";
|
||||
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
||||
import DataTableToolbar from "./DataTableToolbar.vue";
|
||||
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
|
||||
|
|
@ -618,14 +618,7 @@ defineExpose({
|
|||
|
||||
<!-- Client-side pagination -->
|
||||
<template v-else>
|
||||
<DataTablePaginationClient
|
||||
:current-page="table.getState().pagination.pageIndex"
|
||||
:last-page="table.getPageCount()"
|
||||
:total="table.getFilteredRowModel().rows.length"
|
||||
:showing-from="table.getFilteredSelectedRowModel().rows.length"
|
||||
:showing-to="table.getFilteredRowModel().rows.length"
|
||||
:table="table"
|
||||
/>
|
||||
<DataTablePagination :table="table" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ const props = defineProps({
|
|||
showGoto: { type: Boolean, default: true },
|
||||
maxPageLinks: { type: Number, default: 5 },
|
||||
perPage: { type: Number, default: 10 },
|
||||
table: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:page"]);
|
||||
|
|
@ -35,7 +34,7 @@ function goToPageInput() {
|
|||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return;
|
||||
const target = Math.max(1, Math.min(props.lastPage, Math.floor(n)));
|
||||
if (target !== props.currentPage) props.table.setPageIndex(target - 1);
|
||||
if (target !== props.currentPage) setPage(target);
|
||||
gotoInput.value = "";
|
||||
}
|
||||
|
||||
|
|
@ -137,17 +136,14 @@ function setPage(p) {
|
|||
>
|
||||
<PaginationContent>
|
||||
<!-- First -->
|
||||
<PaginationFirst
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.setPageIndex(0)"
|
||||
>
|
||||
<PaginationFirst :disabled="currentPage <= 1" @click="setPage(1)">
|
||||
<ChevronsLeft />
|
||||
</PaginationFirst>
|
||||
|
||||
<!-- Previous -->
|
||||
<PaginationPrevious
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="setPage(currentPage - 1)"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</PaginationPrevious>
|
||||
|
|
@ -158,22 +154,25 @@ function setPage(p) {
|
|||
<PaginationItem
|
||||
v-else
|
||||
:value="item"
|
||||
:is-active="currentPage === index"
|
||||
@click="table.setPageIndex(index)"
|
||||
:is-active="currentPage === item"
|
||||
@click="setPage(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</PaginationItem>
|
||||
</template>
|
||||
|
||||
<!-- Next -->
|
||||
<PaginationNext :disabled="!table.getCanNextPage()" @click="table.nextPage()">
|
||||
<PaginationNext
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="setPage(currentPage + 1)"
|
||||
>
|
||||
<ChevronRight />
|
||||
</PaginationNext>
|
||||
|
||||
<!-- Last -->
|
||||
<PaginationLast
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="setPage(lastPage)"
|
||||
>
|
||||
<ChevronsRight />
|
||||
</PaginationLast>
|
||||
|
|
@ -192,7 +191,7 @@ function setPage(p) {
|
|||
:max="lastPage"
|
||||
inputmode="numeric"
|
||||
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
:placeholder="String(currentPage + 1)"
|
||||
:placeholder="String(currentPage)"
|
||||
aria-label="Pojdi na stran"
|
||||
@keyup.enter="goToPageInput"
|
||||
@blur="goToPageInput"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
import { computed, ref, useAttrs } from "vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Calendar } from "@/Components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/Components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarIcon } from "lucide-vue-next";
|
||||
import { format } from "date-fns";
|
||||
|
|
@ -82,9 +86,7 @@ const toCalendarDate = (value) => {
|
|||
// Convert CalendarDate to ISO string (YYYY-MM-DD)
|
||||
const fromCalendarDate = (calendarDate) => {
|
||||
if (!calendarDate) return null;
|
||||
return `${String(calendarDate.year).padStart(4, "0")}-${String(
|
||||
calendarDate.month
|
||||
).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
|
||||
return `${String(calendarDate.year).padStart(4, "0")}-${String(calendarDate.month).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const calendarDate = computed({
|
||||
|
|
@ -140,10 +142,11 @@ const open = ref(false);
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="start">
|
||||
<Calendar locale="si-SI" v-model="calendarDate" :disabled="disabled" />
|
||||
<Calendar v-model="calendarDate" :disabled="disabled" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p v-if="error" class="mt-1 text-sm text-red-600">
|
||||
{{ Array.isArray(error) ? error[0] : error }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -26,141 +26,6 @@ const loading = ref(false);
|
|||
const previewGenerating = ref(false);
|
||||
const previewError = ref("");
|
||||
|
||||
// Image viewer – zoom & pan state
|
||||
const containerRef = ref(null);
|
||||
const imageRef = ref(null);
|
||||
const imageScale = ref(1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
const fitScale = ref(1);
|
||||
const isDragging = ref(false);
|
||||
const hasMoved = ref(false);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const dragStartTX = ref(0);
|
||||
const dragStartTY = ref(0);
|
||||
|
||||
const MAX_SCALE = 8;
|
||||
|
||||
const imageCursorClass = computed(() => {
|
||||
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
|
||||
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
|
||||
return "cursor-default";
|
||||
});
|
||||
|
||||
const initImageView = () => {
|
||||
const container = containerRef.value;
|
||||
const img = imageRef.value;
|
||||
if (!container || !img) return;
|
||||
const cW = container.clientWidth;
|
||||
const cH = container.clientHeight;
|
||||
const iW = img.naturalWidth || cW;
|
||||
const iH = img.naturalHeight || cH;
|
||||
const fs = Math.min(cW / iW, cH / iH);
|
||||
fitScale.value = fs;
|
||||
imageScale.value = fs;
|
||||
translateX.value = (cW - iW * fs) / 2;
|
||||
translateY.value = (cH - iH * fs) / 2;
|
||||
};
|
||||
|
||||
const resetImageView = () => {
|
||||
initImageView();
|
||||
};
|
||||
|
||||
const clampTranslate = (tx, ty, scale) => {
|
||||
const container = containerRef.value;
|
||||
const img = imageRef.value;
|
||||
if (!container || !img) return { tx, ty };
|
||||
const cW = container.clientWidth;
|
||||
const cH = container.clientHeight;
|
||||
const iW = img.naturalWidth * scale;
|
||||
const iH = img.naturalHeight * scale;
|
||||
// When image fills the container: clamp so image edges stay within container.
|
||||
// When image is smaller than container: keep it centered.
|
||||
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
|
||||
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
|
||||
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
|
||||
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
|
||||
return {
|
||||
tx: Math.min(maxX, Math.max(minX, tx)),
|
||||
ty: Math.min(maxY, Math.max(minY, ty)),
|
||||
};
|
||||
};
|
||||
|
||||
const zoomAt = (mx, my, factor) => {
|
||||
const img = imageRef.value;
|
||||
const iW = img?.naturalWidth ?? 1;
|
||||
const iH = img?.naturalHeight ?? 1;
|
||||
const raw = imageScale.value * factor;
|
||||
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
|
||||
if (newScale === imageScale.value) return;
|
||||
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
|
||||
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
|
||||
const clamped = clampTranslate(tx, ty, newScale);
|
||||
translateX.value = clamped.tx;
|
||||
translateY.value = clamped.ty;
|
||||
imageScale.value = newScale;
|
||||
};
|
||||
|
||||
const mousePos = (e) => {
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
initImageView();
|
||||
};
|
||||
|
||||
const handleWheel = (e) => {
|
||||
e.preventDefault();
|
||||
const { mx, my } = mousePos(e);
|
||||
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDragging.value) return;
|
||||
const dx = e.clientX - dragStartX.value;
|
||||
const dy = e.clientY - dragStartY.value;
|
||||
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
||||
hasMoved.value = true;
|
||||
}
|
||||
if (hasMoved.value) {
|
||||
const clamped = clampTranslate(
|
||||
dragStartTX.value + dx,
|
||||
dragStartTY.value + dy,
|
||||
imageScale.value
|
||||
);
|
||||
translateX.value = clamped.tx;
|
||||
translateY.value = clamped.ty;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isDragging.value = false;
|
||||
setTimeout(() => {
|
||||
hasMoved.value = false;
|
||||
}, 0);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
isDragging.value = true;
|
||||
hasMoved.value = false;
|
||||
dragStartX.value = e.clientX;
|
||||
dragStartY.value = e.clientY;
|
||||
dragStartTX.value = translateX.value;
|
||||
dragStartTY.value = translateY.value;
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
if (props.filename) {
|
||||
return props.filename.split(".").pop()?.toLowerCase() || "";
|
||||
|
|
@ -253,10 +118,6 @@ watch(
|
|||
previewGenerating.value = false;
|
||||
previewError.value = "";
|
||||
docxPreviewUrl.value = "";
|
||||
imageScale.value = 1;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
fitScale.value = 1;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
|
@ -318,50 +179,11 @@ watch(
|
|||
|
||||
<!-- Image Viewer -->
|
||||
<template v-else-if="viewerType === 'image' && props.src">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative h-full overflow-hidden select-none"
|
||||
:class="imageCursorClass"
|
||||
@mousedown="handleMouseDown"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<img
|
||||
ref="imageRef"
|
||||
:src="props.src"
|
||||
:alt="props.title"
|
||||
class="absolute top-0 left-0 max-w-none pointer-events-none"
|
||||
:style="{
|
||||
transformOrigin: '0 0',
|
||||
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.12s ease',
|
||||
}"
|
||||
@load="handleImageLoad"
|
||||
class="max-w-full max-h-full mx-auto object-contain"
|
||||
/>
|
||||
<!-- Zoom level badge -->
|
||||
<div
|
||||
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
|
||||
>
|
||||
{{ Math.round(imageScale * 100) }}%
|
||||
</div>
|
||||
<!-- Reset button -->
|
||||
<Button
|
||||
v-if="imageScale > fitScale + 0.01"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
|
||||
title="Ponastavi pogled"
|
||||
@click.stop="resetImageView"
|
||||
>
|
||||
<RotateCcwIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
<!-- Hint -->
|
||||
<div
|
||||
v-if="imageScale <= fitScale + 0.01"
|
||||
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
|
||||
>
|
||||
Kolesce za povečavo / pomanjšavo · Povleči za premik
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Text/CSV/XML Viewer -->
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { computed, ref, watch } from "vue";
|
|||
import { useForm, Field as FormField } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { router, usePage } from "@inertiajs/vue3";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
|
|
@ -28,22 +27,12 @@ const props = defineProps({
|
|||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
// Decisions with auto_mail = true from shared Inertia data
|
||||
const page = usePage();
|
||||
const decisionOptions = computed(() =>
|
||||
(page.props.auto_mail_decisions ?? []).map((d) => ({
|
||||
value: String(d.id),
|
||||
label: d.name,
|
||||
}))
|
||||
);
|
||||
|
||||
// Zod schema for form validation
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
|
||||
label: z.string().optional(),
|
||||
receive_auto_mails: z.boolean().optional(),
|
||||
decision_ids: z.array(z.string()).optional().default([]),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -54,13 +43,9 @@ const form = useForm({
|
|||
value: "",
|
||||
label: "",
|
||||
receive_auto_mails: false,
|
||||
decision_ids: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Whether to limit sending to specific decisions (UI-only toggle)
|
||||
const limitToDecisions = ref(false);
|
||||
|
||||
const processing = ref(false);
|
||||
|
||||
const close = () => {
|
||||
|
|
@ -72,44 +57,22 @@ const close = () => {
|
|||
};
|
||||
|
||||
const resetForm = () => {
|
||||
limitToDecisions.value = false;
|
||||
form.resetForm({
|
||||
values: {
|
||||
value: "",
|
||||
label: "",
|
||||
receive_auto_mails: false,
|
||||
decision_ids: [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// When auto mails is disabled, collapse the decision filter
|
||||
watch(
|
||||
() => form.values.receive_auto_mails,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
limitToDecisions.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// When limit toggle is turned off, clear the selection
|
||||
watch(limitToDecisions, (val) => {
|
||||
if (!val) {
|
||||
form.setFieldValue("decision_ids", []);
|
||||
}
|
||||
});
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true;
|
||||
const payload = {
|
||||
...form.values,
|
||||
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
|
||||
};
|
||||
const { values } = form;
|
||||
|
||||
router.post(
|
||||
route("person.email.create", props.person),
|
||||
payload,
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
|
|
@ -135,14 +98,11 @@ const create = async () => {
|
|||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
const payload = {
|
||||
...form.values,
|
||||
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
|
||||
};
|
||||
const { values } = form;
|
||||
|
||||
router.put(
|
||||
route("person.email.update", { person: props.person, email_id: props.id }),
|
||||
payload,
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
|
|
@ -176,13 +136,10 @@ watch(
|
|||
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
|
||||
const email = list.find((e) => e.id === props.id);
|
||||
if (email) {
|
||||
const existingDecisionIds = (email.preferences?.decision_ids ?? []).map(String);
|
||||
limitToDecisions.value = existingDecisionIds.length > 0;
|
||||
form.setValues({
|
||||
value: email.value ?? email.email ?? email.address ?? "",
|
||||
label: email.label ?? "",
|
||||
receive_auto_mails: !!email.receive_auto_mails,
|
||||
decision_ids: existingDecisionIds,
|
||||
});
|
||||
} else {
|
||||
resetForm();
|
||||
|
|
@ -271,36 +228,6 @@ const onConfirm = () => {
|
|||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Limit to specific decisions — only shown when receive_auto_mails is on and decisions exist -->
|
||||
<template v-if="(props.person?.client || isClientContext) && form.values.receive_auto_mails && decisionOptions.length > 0">
|
||||
<div class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<Switch
|
||||
:model-value="limitToDecisions"
|
||||
@update:model-value="(val) => (limitToDecisions = val)"
|
||||
/>
|
||||
<div class="space-y-1 leading-none">
|
||||
<label class="text-sm font-medium leading-none cursor-pointer" @click="limitToDecisions = !limitToDecisions">
|
||||
Omeji na posamezne odločitve
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField v-if="limitToDecisions" v-slot="{ value, handleChange }" name="decision_ids">
|
||||
<FormItem>
|
||||
<FormLabel>Odločitve, za katere se pošlje e-pošta</FormLabel>
|
||||
<FormControl>
|
||||
<AppMultiSelect
|
||||
:model-value="value ?? []"
|
||||
:items="decisionOptions"
|
||||
placeholder="Izberi odločitve..."
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
InboxIcon,
|
||||
AtSignIcon,
|
||||
BookUserIcon,
|
||||
MessageSquareIcon,
|
||||
ArrowLeftIcon,
|
||||
} from "lucide-vue-next";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
|
|
@ -210,6 +211,13 @@ const navGroups = computed(() => [
|
|||
icon: Settings2Icon,
|
||||
active: ["admin.sms-profiles.index"],
|
||||
},
|
||||
{
|
||||
key: "admin.packages.index",
|
||||
label: "SMS paketi",
|
||||
route: "admin.packages.index",
|
||||
icon: MessageSquareIcon,
|
||||
active: ["admin.packages.index", "admin.packages.show"],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -26,9 +26,6 @@ import { SettingsIcon } from "lucide-vue-next";
|
|||
import { ShieldUserIcon } from "lucide-vue-next";
|
||||
import { SmartphoneIcon } from "lucide-vue-next";
|
||||
import { TabletSmartphoneIcon } from "lucide-vue-next";
|
||||
import { PhoneCallIcon } from "lucide-vue-next";
|
||||
import { PackageIcon } from "lucide-vue-next";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
|
|
@ -160,13 +157,6 @@ const rawMenuGroups = [
|
|||
routeName: "segments.index",
|
||||
active: ["segments.index"],
|
||||
},
|
||||
{
|
||||
key: "call-laters",
|
||||
icon: PhoneCallIcon,
|
||||
title: "Pokliči kasneje",
|
||||
routeName: "callLaters.index",
|
||||
active: ["callLaters.index"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -222,13 +212,6 @@ const rawMenuGroups = [
|
|||
routeName: "settings",
|
||||
active: ["settings", "settings.*"],
|
||||
},
|
||||
{
|
||||
key: "packages",
|
||||
icon: PackageIcon,
|
||||
title: "SMS paketi",
|
||||
routeName: "packages.index",
|
||||
active: ["packages.index", "packages.show", "packages.create"],
|
||||
},
|
||||
// Admin panel (roles & permissions management)
|
||||
// Only shown if current user has admin role or manage-settings permission.
|
||||
// We'll filter it out below if not authorized.
|
||||
|
|
@ -285,14 +268,6 @@ function isActive(patterns) {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getBadge(item) {
|
||||
if (item.key === "call-laters") {
|
||||
return page.props.callLaterCount || 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -366,18 +341,11 @@ function getBadge(item) {
|
|||
<!-- Title -->
|
||||
<span
|
||||
v-if="!sidebarCollapsed"
|
||||
class="flex-1 truncate transition-opacity"
|
||||
class="truncate transition-opacity"
|
||||
:class="{ 'font-medium': isActive(item.active) }"
|
||||
>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="!sidebarCollapsed && getBadge(item) > 0"
|
||||
variant="destructive"
|
||||
class="ml-auto shrink-0 px-1.5 py-0.5 text-xs font-mono text-amber-50"
|
||||
>
|
||||
{{ getBadge(item) }}
|
||||
</Badge>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -107,6 +107,12 @@ const cards = [
|
|||
route: "admin.sms-logs.index",
|
||||
icon: InboxIcon,
|
||||
},
|
||||
{
|
||||
title: "SMS paketi",
|
||||
description: "Kreiranje in pošiljanje serijskih SMS paketov",
|
||||
route: "admin.packages.index",
|
||||
icon: MessageSquareIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Link, router, useForm } from "@inertiajs/vue3";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import axios from "axios";
|
||||
|
|
@ -112,9 +112,9 @@ function submitCreate() {
|
|||
})),
|
||||
};
|
||||
|
||||
router.post(route("packages.store"), payload, {
|
||||
router.post(route("admin.packages.store"), payload, {
|
||||
onSuccess: () => {
|
||||
router.visit(route("packages.index"));
|
||||
router.visit(route("admin.packages.index"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -202,7 +202,7 @@ async function loadContracts(url = null) {
|
|||
if (onlyValidated.value) params.append("only_validated", "1");
|
||||
params.append("per_page", perPage.value);
|
||||
|
||||
const target = url || `${route("packages.contracts")}?${params.toString()}`;
|
||||
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
const { data: json } = await axios.get(target, {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
|
|
@ -268,7 +268,7 @@ function goToPage(page) {
|
|||
params.append("per_page", perPage.value);
|
||||
params.append("page", page);
|
||||
|
||||
const url = `${route("packages.contracts")}?${params.toString()}`;
|
||||
const url = `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
loadContracts(url);
|
||||
}
|
||||
|
||||
|
|
@ -312,9 +312,9 @@ function submitCreateFromContracts() {
|
|||
};
|
||||
|
||||
creatingFromContracts.value = true;
|
||||
router.post(route("packages.store-from-contracts"), payload, {
|
||||
router.post(route("admin.packages.store-from-contracts"), payload, {
|
||||
onSuccess: () => {
|
||||
router.visit(route("packages.index"));
|
||||
router.visit(route("admin.packages.index"));
|
||||
},
|
||||
onError: (errors) => {
|
||||
const first = errors && Object.values(errors)[0];
|
||||
|
|
@ -337,11 +337,11 @@ const numbersCount = computed(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Ustvari SMS paket">
|
||||
<AdminLayout title="Ustvari SMS paket">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<Link :href="route('packages.index')">
|
||||
<Link :href="route('admin.packages.index')">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||
Nazaj
|
||||
|
|
@ -520,7 +520,7 @@ const numbersCount = computed(() => {
|
|||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
@click="router.visit(route('packages.index'))"
|
||||
@click="router.visit(route('admin.packages.index'))"
|
||||
variant="outline"
|
||||
>
|
||||
Prekliči
|
||||
|
|
@ -703,7 +703,7 @@ const numbersCount = computed(() => {
|
|||
Izbrano: {{ selectedContractIds.size }}
|
||||
</Badge>
|
||||
<Button
|
||||
@click="router.visit(route('packages.index'))"
|
||||
@click="router.visit(route('admin.packages.index'))"
|
||||
variant="outline"
|
||||
>
|
||||
Prekliči
|
||||
|
|
@ -806,5 +806,5 @@ const numbersCount = computed(() => {
|
|||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</AppLayout>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
|
|
@ -48,7 +48,7 @@ function getStatusVariant(status) {
|
|||
}
|
||||
|
||||
function goShow(id) {
|
||||
router.visit(route("packages.show", id));
|
||||
router.visit(route("admin.packages.show", id));
|
||||
}
|
||||
|
||||
function openDeleteDialog(pkg) {
|
||||
|
|
@ -60,7 +60,7 @@ function openDeleteDialog(pkg) {
|
|||
function confirmDelete() {
|
||||
if (!packageToDelete.value) return;
|
||||
deletingId.value = packageToDelete.value.id;
|
||||
router.delete(route("packages.destroy", packageToDelete.value.id), {
|
||||
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
|
||||
onSuccess: () => {
|
||||
router.reload({ only: ["packages"] });
|
||||
},
|
||||
|
|
@ -74,7 +74,7 @@ function confirmDelete() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="SMS paketi">
|
||||
<AdminLayout title="SMS paketi">
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
@ -82,7 +82,7 @@ function confirmDelete() {
|
|||
<PackageIcon class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>SMS paketi</CardTitle>
|
||||
</div>
|
||||
<Link :href="route('packages.create')">
|
||||
<Link :href="route('admin.packages.create')">
|
||||
<Button>
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
Nov paket
|
||||
|
|
@ -109,7 +109,7 @@ function confirmDelete() {
|
|||
:columns="columns"
|
||||
:data="packages.data"
|
||||
:meta="packages"
|
||||
route-name="packages.index"
|
||||
route-name="admin.packages.index"
|
||||
>
|
||||
<template #cell-name="{ row }">
|
||||
<span class="text-sm">{{ row.name ?? "—" }}</span>
|
||||
|
|
@ -172,5 +172,5 @@ function confirmDelete() {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AppLayout>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { onMounted, onUnmounted, ref, computed } from "vue";
|
||||
import {
|
||||
|
|
@ -88,14 +88,14 @@ function reload() {
|
|||
|
||||
function dispatchPkg() {
|
||||
router.post(
|
||||
route("packages.dispatch", props.package.id),
|
||||
route("admin.packages.dispatch", props.package.id),
|
||||
{},
|
||||
{ onSuccess: reload }
|
||||
);
|
||||
}
|
||||
function cancelPkg() {
|
||||
router.post(
|
||||
route("packages.cancel", props.package.id),
|
||||
route("admin.packages.cancel", props.package.id),
|
||||
{},
|
||||
{ onSuccess: reload }
|
||||
);
|
||||
|
|
@ -132,7 +132,7 @@ async function copyText(text) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :title="`Paket #${package.id}`">
|
||||
<AdminLayout :title="`Paket #${package.id}`">
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
@ -147,7 +147,7 @@ async function copyText(text) {
|
|||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" as-child>
|
||||
<Link :href="route('packages.index')">
|
||||
<Link :href="route('admin.packages.index')">
|
||||
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||
Nazaj
|
||||
</Link>
|
||||
|
|
@ -281,7 +281,7 @@ async function copyText(text) {
|
|||
:columns="columns"
|
||||
:data="items.data"
|
||||
:meta="items"
|
||||
route-name="packages.show"
|
||||
route-name="admin.packages.show"
|
||||
:route-params="{ id: package.id }"
|
||||
>
|
||||
<template #cell-target="{ row }">
|
||||
|
|
@ -333,5 +333,5 @@ async function copyText(text) {
|
|||
<div v-if="refreshing" class="mt-2 text-xs text-muted-foreground">
|
||||
Osveževanje ...
|
||||
</div>
|
||||
</AppLayout>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
|
@ -2,13 +2,7 @@
|
|||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { useForm, Link } from "@inertiajs/vue3";
|
||||
import { KeyRoundIcon, ArrowLeftIcon, SaveIcon } from "lucide-vue-next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
|
|
@ -42,16 +36,12 @@ function submit() {
|
|||
<CardHeader>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"
|
||||
>
|
||||
<div class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary">
|
||||
<KeyRoundIcon class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Uredi dovoljenje</CardTitle>
|
||||
<CardDescription
|
||||
>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription
|
||||
>
|
||||
<CardDescription>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" as-child>
|
||||
|
|
@ -63,6 +53,7 @@ function submit() {
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-6">
|
||||
<div class="grid sm:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
|
|
@ -95,19 +86,16 @@ function submit() {
|
|||
class="flex items-center gap-2 text-sm cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
:default-value="form.roles.includes(r.id)"
|
||||
@update:model-value="
|
||||
(checked) => {
|
||||
if (checked) form.roles.push(r.id);
|
||||
else form.roles = form.roles.filter((id) => id !== r.id);
|
||||
}
|
||||
"
|
||||
:value="r.id"
|
||||
:checked="form.roles.includes(r.id)"
|
||||
@update:checked="(checked) => {
|
||||
if (checked) form.roles.push(r.id)
|
||||
else form.roles = form.roles.filter(id => id !== r.id)
|
||||
}"
|
||||
/>
|
||||
<span
|
||||
><span class="font-medium">{{ r.name }}</span>
|
||||
<span class="text-xs text-muted-foreground"
|
||||
>({{ r.slug }})</span
|
||||
></span
|
||||
<span class="text-xs text-muted-foreground">({{ r.slug }})</span></span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,288 +0,0 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { computed, ref } from "vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import CardTitle from "@/Components/ui/card/CardTitle.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
import {
|
||||
PhoneCallIcon,
|
||||
CheckIcon,
|
||||
Filter,
|
||||
ExternalLinkIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import { fmtDateTime } from "@/Utilities/functions";
|
||||
|
||||
const props = defineProps({
|
||||
callLaters: Object,
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
const search = ref(props.filters?.search || "");
|
||||
const dateFrom = ref(props.filters?.date_from || "");
|
||||
const dateTo = ref(props.filters?.date_to || "");
|
||||
const filterPopoverOpen = ref(false);
|
||||
|
||||
const appliedFilterCount = computed(() => {
|
||||
let count = 0;
|
||||
if (search.value?.trim()) count += 1;
|
||||
if (dateFrom.value) count += 1;
|
||||
if (dateTo.value) count += 1;
|
||||
return count;
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
filterPopoverOpen.value = false;
|
||||
const params = {};
|
||||
if (search.value?.trim()) {
|
||||
params.search = search.value.trim();
|
||||
}
|
||||
if (dateFrom.value) {
|
||||
params.date_from = dateFrom.value;
|
||||
}
|
||||
if (dateTo.value) {
|
||||
params.date_to = dateTo.value;
|
||||
}
|
||||
router.get(route("callLaters.index"), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
search.value = "";
|
||||
dateFrom.value = "";
|
||||
dateTo.value = "";
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function markDone(item) {
|
||||
router.patch(
|
||||
route("callLaters.complete", item.id),
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function openAndComplete(item) {
|
||||
router.patch(
|
||||
route("callLaters.complete", item.id),
|
||||
{},
|
||||
{
|
||||
preserveScroll: false,
|
||||
onSuccess: () => {
|
||||
if (item.client_case?.uuid) {
|
||||
router.visit(route("clientCase.show", { client_case: item.client_case.uuid }));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function isOverdue(item) {
|
||||
if (!item.call_back_at) return false;
|
||||
// Strip Z so the value is parsed as local time (datetimes are stored as local time with a wrong Z suffix)
|
||||
const localDateStr = item.call_back_at.replace("Z", "").replace("T", " ");
|
||||
return new Date(localDateStr) < new Date();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: "person", label: "Stranka / Primer", sortable: false },
|
||||
{ key: "contract", label: "Pogodba", sortable: false },
|
||||
{ key: "call_back_at", label: "Datum klica", sortable: false },
|
||||
{ key: "user", label: "Agent", sortable: false },
|
||||
{ key: "note", label: "Opomba", sortable: false },
|
||||
{ key: "actions", label: "", sortable: false, class: "w-12" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Pokliči kasneje">
|
||||
<template #header></template>
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<PhoneCallIcon :size="18" />
|
||||
<CardTitle class="uppercase">Pokliči kasneje</CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="callLaters.data || []"
|
||||
:meta="callLaters"
|
||||
:search="search"
|
||||
route-name="callLaters.index"
|
||||
:show-toolbar="true"
|
||||
:show-pagination="false"
|
||||
:hoverable="true"
|
||||
row-key="id"
|
||||
empty-text="Ni zakazanih klicev."
|
||||
:row-class="(row) => (isOverdue(row) ? 'bg-red-50 dark:bg-red-950/20' : '')"
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<AppPopover
|
||||
v-model:open="filterPopoverOpen"
|
||||
align="start"
|
||||
content-class="w-[420px]"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
<Filter class="h-4 w-4" />
|
||||
Filtri
|
||||
<span
|
||||
v-if="appliedFilterCount > 0"
|
||||
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
|
||||
>
|
||||
{{ appliedFilterCount }}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Filtri klicev</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Iskanje (stranka)</InputLabel>
|
||||
<Input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Ime stranke..."
|
||||
@keydown.enter="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Datum od</InputLabel>
|
||||
<Input v-model="dateFrom" type="date" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Datum do</InputLabel>
|
||||
<Input v-model="dateTo" type="date" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="appliedFilterCount === 0"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Počisti
|
||||
</Button>
|
||||
<Button type="button" size="sm" @click="applyFilters">
|
||||
Uporabi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPopover>
|
||||
</template>
|
||||
|
||||
<template #cell-person="{ row }">
|
||||
<div>
|
||||
<Link
|
||||
v-if="row.client_case"
|
||||
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
|
||||
class="font-medium text-indigo-600 hover:underline"
|
||||
>
|
||||
{{ row.client_case.person?.full_name || "-" }}
|
||||
</Link>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-contract="{ row }">
|
||||
<span v-if="row.contract">{{ row.contract.reference }}</span>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-call_back_at="{ row }">
|
||||
<span
|
||||
:class="[
|
||||
'font-medium',
|
||||
isOverdue(row) ? 'text-red-600 dark:text-red-400' : '',
|
||||
]"
|
||||
>
|
||||
{{ fmtDateTime(row.call_back_at) }}
|
||||
</span>
|
||||
<span v-if="isOverdue(row)" class="ml-2 text-xs text-red-500 font-semibold">
|
||||
Zamuda
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-user="{ row }">
|
||||
<span v-if="row.user">{{ row.user.name }}</span>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-note="{ row }">
|
||||
<span class="line-clamp-2 text-sm text-muted-foreground">
|
||||
{{ row.activity?.note || "-" }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button size="icon" variant="ghost" class="h-8 w-8">
|
||||
<MoreHorizontalIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="markDone(row)">
|
||||
<CheckIcon class="mr-2 h-4 w-4" />
|
||||
Opravljeno
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="row.client_case?.uuid"
|
||||
@click="openAndComplete(row)"
|
||||
>
|
||||
<ExternalLinkIcon class="mr-2 h-4 w-4" />
|
||||
Odpri in opravi
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<Pagination
|
||||
:links="callLaters.links"
|
||||
:from="callLaters.from"
|
||||
:to="callLaters.to"
|
||||
:total="callLaters.total"
|
||||
:per-page="callLaters.per_page || 50"
|
||||
:last-page="callLaters.last_page"
|
||||
:current-page="callLaters.current_page"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -58,8 +58,6 @@ const form = useInertiaForm({
|
|||
send_auto_mail: true,
|
||||
attach_documents: false,
|
||||
attachment_document_ids: [],
|
||||
call_back_at_date: null,
|
||||
call_back_at_time: null,
|
||||
});
|
||||
|
||||
watch(
|
||||
|
|
@ -129,20 +127,6 @@ const store = async () => {
|
|||
|
||||
const isMultipleContracts = contractUuids && contractUuids.length > 1;
|
||||
|
||||
const buildCallBackAt = (date, time) => {
|
||||
if (!date) return null;
|
||||
const t = time || '00:00';
|
||||
const [h, m] = t.split(':');
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
const y = d.getFullYear();
|
||||
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dy = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(Number(h || 0)).padStart(2, '0');
|
||||
const mm = String(Number(m || 0)).padStart(2, '0');
|
||||
return `${y}-${mo}-${dy} ${hh}:${mm}:00`;
|
||||
};
|
||||
|
||||
form
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
|
|
@ -154,16 +138,11 @@ const store = async () => {
|
|||
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
|
||||
? data.attachment_document_ids
|
||||
: [],
|
||||
call_back_at: hasCallLaterEvent.value
|
||||
? buildCallBackAt(data.call_back_at_date, data.call_back_at_time)
|
||||
: null,
|
||||
call_back_at_date: undefined,
|
||||
call_back_at_time: undefined,
|
||||
}))
|
||||
.post(route("clientCase.activity.store", props.client_case), {
|
||||
onSuccess: () => {
|
||||
close();
|
||||
form.reset("due_date", "amount", "note", "contract_uuids", "call_back_at_date", "call_back_at_time");
|
||||
form.reset("due_date", "amount", "note", "contract_uuids");
|
||||
emit("saved");
|
||||
},
|
||||
});
|
||||
|
|
@ -177,22 +156,6 @@ const currentDecision = () => {
|
|||
decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0] || null
|
||||
);
|
||||
};
|
||||
|
||||
const hasCallLaterEvent = computed(() => {
|
||||
const d = currentDecision();
|
||||
if (!d) return false;
|
||||
return Array.isArray(d.events) && d.events.some((e) => e.key === 'add_call_later');
|
||||
});
|
||||
|
||||
watch(
|
||||
() => hasCallLaterEvent.value,
|
||||
(has) => {
|
||||
if (!has) {
|
||||
form.call_back_at_date = null;
|
||||
form.call_back_at_time = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
const showSendAutoMail = () => {
|
||||
const d = currentDecision();
|
||||
return !!(d && d.auto_mail && d.email_template_id);
|
||||
|
|
@ -446,26 +409,6 @@ watch(
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasCallLaterEvent" class="space-y-2">
|
||||
<Label>Datum in ura povratnega klica</Label>
|
||||
<div class="flex gap-2">
|
||||
<DatePicker
|
||||
v-model="form.call_back_at_date"
|
||||
format="dd.MM.yyyy"
|
||||
:error="form.errors.call_back_at"
|
||||
class="flex-1"
|
||||
/>
|
||||
<input
|
||||
v-model="form.call_back_at_time"
|
||||
type="time"
|
||||
class="flex-1 border rounded-md px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="form.errors.call_back_at" class="text-xs text-destructive">
|
||||
{{ form.errors.call_back_at }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="activityAmount">Znesek</Label>
|
||||
<CurrencyInput
|
||||
|
|
@ -537,7 +480,7 @@ watch(
|
|||
/>
|
||||
<div class="wrap-anywhere">
|
||||
<p>
|
||||
<span>{{ doc.name }}.{{ doc.extension }}</span>
|
||||
{{ doc.original_name || doc.name }}
|
||||
</p>
|
||||
<span class="text-xs text-gray-400"
|
||||
>({{ doc.extension?.toUpperCase() || "" }},
|
||||
|
|
|
|||
|
|
@ -741,16 +741,8 @@ const copyToClipboard = async (text) => {
|
|||
<span class="text-gray-500">D:</span>
|
||||
<span class="ml-1">{{ fmtDate(row.due_date) }}</span>
|
||||
</div>
|
||||
<div v-if="row.call_back_at" class="leading-tight">
|
||||
<span class="text-gray-500">K:</span>
|
||||
<span class="ml-1">{{ fmtDateTime(row.call_back_at) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!row.due_date &&
|
||||
(!row.amount || Number(row.amount) === 0) &&
|
||||
!row.call_back_at
|
||||
"
|
||||
v-if="!row.due_date && (!row.amount || Number(row.amount) === 0)"
|
||||
class="text-gray-400"
|
||||
>
|
||||
—
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
|
|||
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
|
||||
import PaymentDialog from "./PaymentDialog.vue";
|
||||
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
|
||||
import InstallmentDialog from "./InstallmentDialog.vue";
|
||||
import ViewInstallmentsDialog from "./ViewInstallmentsDialog.vue";
|
||||
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
|
||||
|
|
@ -33,7 +31,6 @@ import {
|
|||
faSpinner,
|
||||
faTags,
|
||||
faFolderOpen,
|
||||
faArrowUp,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import EmptyState from "@/Components/EmptyState.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
|
|
@ -447,52 +444,6 @@ const closePaymentsDialog = () => {
|
|||
selectedContract.value = null;
|
||||
};
|
||||
|
||||
// Installments
|
||||
const showInstallmentDialog = ref(false);
|
||||
const installmentContract = ref(null);
|
||||
const installmentForm = useForm({
|
||||
amount: null,
|
||||
currency: "EUR",
|
||||
installment_at: null,
|
||||
reference: "",
|
||||
});
|
||||
|
||||
const openInstallmentDialog = (c) => {
|
||||
installmentContract.value = c;
|
||||
installmentForm.reset();
|
||||
installmentForm.installment_at = todayStr.value;
|
||||
showInstallmentDialog.value = true;
|
||||
};
|
||||
|
||||
const closeInstallmentDialog = () => {
|
||||
showInstallmentDialog.value = false;
|
||||
installmentContract.value = null;
|
||||
};
|
||||
|
||||
const submitInstallment = () => {
|
||||
if (!installmentContract.value?.account?.id) return;
|
||||
const accountId = installmentContract.value.account.id;
|
||||
installmentForm.post(route("accounts.installments.store", { account: accountId }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
closeInstallmentDialog();
|
||||
router.reload({ only: ["contracts", "activities"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showInstallmentsDialog = ref(false);
|
||||
|
||||
const openInstallmentsDialog = (c) => {
|
||||
selectedContract.value = c;
|
||||
showInstallmentsDialog.value = true;
|
||||
};
|
||||
|
||||
const closeInstallmentsDialog = () => {
|
||||
showInstallmentsDialog.value = false;
|
||||
selectedContract.value = null;
|
||||
};
|
||||
|
||||
// Meta edit dialog
|
||||
const showMetaEditDialog = ref(false);
|
||||
|
||||
|
|
@ -538,7 +489,7 @@ const availableSegmentsCount = computed(() => {
|
|||
:empty-icon="faFolderOpen"
|
||||
empty-text="Ni pogodb"
|
||||
empty-description="Za ta primer še ni ustvarjenih pogodb. Ustvarite novo pogodbo za začetek."
|
||||
:show-pagination="true"
|
||||
:show-pagination="false"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
>
|
||||
|
|
@ -799,6 +750,7 @@ const availableSegmentsCount = computed(() => {
|
|||
|
||||
<!-- Add Activity -->
|
||||
<ActionMenuItem
|
||||
v-if="row.active"
|
||||
:icon="faListCheck"
|
||||
label="Dodaj aktivnost"
|
||||
@click="onAddActivity(row)"
|
||||
|
|
@ -881,26 +833,6 @@ const availableSegmentsCount = computed(() => {
|
|||
@click="openPaymentDialog(row)"
|
||||
/>
|
||||
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
|
||||
<!-- Installments -->
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
Obroki
|
||||
</div>
|
||||
<ActionMenuItem
|
||||
:icon="faCircleInfo"
|
||||
label="Pokaži obroke"
|
||||
@click="openInstallmentsDialog(row)"
|
||||
/>
|
||||
<ActionMenuItem
|
||||
v-if="row.active && row?.account"
|
||||
:icon="faArrowUp"
|
||||
label="Dodaj obrok"
|
||||
@click="openInstallmentDialog(row)"
|
||||
/>
|
||||
|
||||
<!-- Archive -->
|
||||
<template v-if="edit">
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
|
|
@ -1006,20 +938,6 @@ const availableSegmentsCount = computed(() => {
|
|||
:edit="edit"
|
||||
/>
|
||||
|
||||
<InstallmentDialog
|
||||
:show="showInstallmentDialog"
|
||||
:form="installmentForm"
|
||||
@close="closeInstallmentDialog"
|
||||
@submit="submitInstallment"
|
||||
/>
|
||||
|
||||
<ViewInstallmentsDialog
|
||||
:show="showInstallmentsDialog"
|
||||
:contract="selectedContract"
|
||||
@close="closeInstallmentsDialog"
|
||||
:edit="edit"
|
||||
/>
|
||||
|
||||
<ContractMetaEditDialog
|
||||
:show="showMetaEditDialog"
|
||||
:client_case="client_case"
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
<script setup>
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import CurrencyInput from "@/Components/CurrencyInput.vue";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import DatePicker from "@/Components/DatePicker.vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
form: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "submit"]);
|
||||
|
||||
const onClose = () => emit("close");
|
||||
const onSubmit = () => emit("submit");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreateDialog
|
||||
:show="show"
|
||||
title="Dodaj obrok"
|
||||
confirm-text="Shrani"
|
||||
:processing="form.processing"
|
||||
@close="onClose"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="installmentAmount">Znesek</Label>
|
||||
<CurrencyInput
|
||||
id="installmentAmount"
|
||||
v-model="form.amount"
|
||||
:precision="{ min: 0, max: 2 }"
|
||||
placeholder="0,00"
|
||||
class="w-full"
|
||||
/>
|
||||
<p v-if="form.errors?.amount" class="text-sm text-red-600">
|
||||
{{ form.errors.amount }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="installmentCurrency">Valuta</Label>
|
||||
<Input
|
||||
id="installmentCurrency"
|
||||
type="text"
|
||||
v-model="form.currency"
|
||||
maxlength="3"
|
||||
placeholder="EUR"
|
||||
/>
|
||||
<p v-if="form.errors?.currency" class="text-sm text-red-600">
|
||||
{{ form.errors.currency }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="installmentDate">Datum</Label>
|
||||
<DatePicker
|
||||
id="installmentDate"
|
||||
v-model="form.installment_at"
|
||||
format="dd.MM.yyyy"
|
||||
:error="form.errors?.installment_at"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="installmentReference">Sklic</Label>
|
||||
<Input
|
||||
id="installmentReference"
|
||||
type="text"
|
||||
v-model="form.reference"
|
||||
placeholder="Sklic"
|
||||
/>
|
||||
<p v-if="form.errors?.reference" class="text-sm text-red-600">
|
||||
{{ form.errors.reference }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CreateDialog>
|
||||
</template>
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
<script setup>
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
contract: { type: Object, default: null },
|
||||
edit: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const installments = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const contractRef = computed(() => props.contract?.reference || "—");
|
||||
const accountId = computed(() => props.contract?.account?.id || null);
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return "-";
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt.getTime()) ? "-" : dt.toLocaleDateString("de");
|
||||
}
|
||||
|
||||
async function loadInstallments() {
|
||||
if (!accountId.value) {
|
||||
installments.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
route("accounts.installments.list", { account: accountId.value })
|
||||
);
|
||||
installments.value = data.installments || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("close");
|
||||
installments.value = [];
|
||||
}
|
||||
|
||||
function deleteInstallment(installmentId) {
|
||||
if (!accountId.value) return;
|
||||
router.delete(
|
||||
route("accounts.installments.destroy", {
|
||||
account: accountId.value,
|
||||
installment: installmentId,
|
||||
}),
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
only: ["contracts", "activities"],
|
||||
onSuccess: async () => {
|
||||
await loadInstallments();
|
||||
},
|
||||
onError: async () => {
|
||||
await loadInstallments();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (visible) => {
|
||||
if (visible) {
|
||||
await loadInstallments();
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.contract?.account?.id,
|
||||
async () => {
|
||||
if (props.show) {
|
||||
await loadInstallments();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>
|
||||
Obroki za pogodbo <span class="text-gray-600">{{ contractRef }}</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div>
|
||||
<div v-if="loading" class="text-sm text-gray-500">Nalaganje…</div>
|
||||
<template v-else>
|
||||
<div v-if="installments.length === 0" class="text-sm text-gray-500">Ni obrokov.</div>
|
||||
<div v-else class="divide-y divide-gray-100 border rounded">
|
||||
<div
|
||||
v-for="i in installments"
|
||||
:key="i.id"
|
||||
class="px-3 py-2 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm text-gray-800">
|
||||
{{
|
||||
Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: i.currency || "EUR",
|
||||
}).format(i.amount ?? 0)
|
||||
}}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span>{{ formatDate(i.installment_at) }}</span>
|
||||
<span v-if="i.reference" class="ml-2">Sklic: {{ i.reference }}</span>
|
||||
<span v-if="i.balance_before !== undefined" class="ml-2">
|
||||
Stanje pred:
|
||||
{{
|
||||
Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: i.currency || "EUR",
|
||||
}).format(i.balance_before ?? 0)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" v-if="edit">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
|
||||
@click="deleteInstallment(i.id)"
|
||||
title="Izbriši obrok"
|
||||
>
|
||||
<span class="text-sm">Briši</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||
@click="loadInstallments"
|
||||
>
|
||||
Osveži
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
@click="close"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
|
@ -31,7 +31,7 @@ import {
|
|||
const props = defineProps({
|
||||
client: Object,
|
||||
client_case: Object,
|
||||
contracts: { type: Array, default: () => [] }, // Resource Collection with data/links/meta
|
||||
contracts: Object, // Resource Collection with data/links/meta
|
||||
activities: Object, // Resource Collection with data/links/meta
|
||||
contract_types: Array,
|
||||
account_types: { type: Array, default: () => [] },
|
||||
|
|
@ -46,7 +46,7 @@ const props = defineProps({
|
|||
|
||||
// Extract contracts array from Resource Collection
|
||||
const contractsArray = computed(() => {
|
||||
return props.contracts || [];
|
||||
return props.contracts?.data || [];
|
||||
});
|
||||
|
||||
// Contracts are always paginated now (Resource Collection)
|
||||
|
|
@ -356,6 +356,19 @@ const submitAttachSegment = () => {
|
|||
@create="openDrawerCreateContract"
|
||||
@attach-segment="openAttachSegment"
|
||||
/>
|
||||
<div v-if="contractsPaginated" class="border-t border-gray-200 p-4">
|
||||
<Pagination
|
||||
:links="contracts.links"
|
||||
:from="contracts.from"
|
||||
:to="contracts.to"
|
||||
:total="contracts.total"
|
||||
:per-page="contracts.per_page || 50"
|
||||
:last-page="contracts.last_page"
|
||||
:current-page="contracts.current_page"
|
||||
per-page-param="contracts_per_page"
|
||||
page-param="contracts_page"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
Archive,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
CalendarDays,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const settingsCards = [
|
||||
|
|
@ -28,12 +27,6 @@ const settingsCards = [
|
|||
route: "settings.payment.edit",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
title: "Installments",
|
||||
description: "Defaults for installments and auto-activity.",
|
||||
route: "settings.installment.edit",
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
title: "Workflow",
|
||||
description: "Configure actions and decisions relationships.",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup>
|
||||
// flowbite-vue table imports removed; using DataTableClient
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -26,7 +27,7 @@ import { Input } from "@/Components/ui/input";
|
|||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import { FilterIcon, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
|
||||
|
|
@ -59,13 +60,16 @@ const segmentOptions = computed(() =>
|
|||
}))
|
||||
);
|
||||
|
||||
// DataTable state
|
||||
const sort = ref({ key: null, direction: null });
|
||||
const page = ref(1);
|
||||
const pageSize = ref(25);
|
||||
const columns = [
|
||||
{ key: "id", label: "#", sortable: true, class: "w-16" },
|
||||
{ key: "name", label: "Ime", sortable: true },
|
||||
{ key: "color_tag", label: "Barva", sortable: false },
|
||||
{ key: "segment", label: "Segment", sortable: false },
|
||||
{ key: "decisions", label: "Odločitve", sortable: false, class: "w-32" },
|
||||
{ key: "actions", label: "", sortable: false, class: "w-12" },
|
||||
];
|
||||
|
||||
const form = useForm({
|
||||
|
|
@ -227,12 +231,18 @@ const destroyAction = () => {
|
|||
<Button @click="openCreateDrawer">+ Dodaj akcijo</Button>
|
||||
</div>
|
||||
<div>
|
||||
<DataTableNew2
|
||||
<DataTableClient
|
||||
:columns="columns"
|
||||
:data="filtered"
|
||||
:pageSize="25"
|
||||
:rows="filtered"
|
||||
:sort="sort"
|
||||
:search="''"
|
||||
:page="page"
|
||||
:pageSize="pageSize"
|
||||
:showToolbar="false"
|
||||
:showPagination="true"
|
||||
@update:sort="(v) => (sort = v)"
|
||||
@update:page="(v) => (page = v)"
|
||||
@update:pageSize="(v) => (pageSize = v)"
|
||||
>
|
||||
<template #cell-color_tag="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -252,7 +262,7 @@ const destroyAction = () => {
|
|||
{{ row.segment?.name || "" }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<template #actions="{ row }">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon">
|
||||
|
|
@ -275,7 +285,7 @@ const destroyAction = () => {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
</DataTableClient>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="drawerEdit">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup>
|
||||
import { DottedMenu } from "@/Utilities/Icons";
|
||||
// flowbite-vue table imports removed; using DataTableClient
|
||||
import { EditIcon, TrashBinIcon, DottedMenu } from "@/Utilities/Icons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -29,11 +30,11 @@ import {
|
|||
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import { FilterIcon, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
|
||||
import { FilterIcon, Trash2, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -63,6 +64,10 @@ const selectedEvents = ref([]);
|
|||
|
||||
const actionOptions = ref([]);
|
||||
|
||||
// DataTable state
|
||||
const sort = ref({ key: null, direction: null });
|
||||
const page = ref(1);
|
||||
const pageSize = ref(25);
|
||||
const columns = [
|
||||
{ key: "id", label: "#", sortable: true },
|
||||
{ key: "name", label: "Ime", sortable: true },
|
||||
|
|
@ -70,7 +75,6 @@ const columns = [
|
|||
{ key: "events", label: "Dogodki", sortable: false },
|
||||
{ key: "belongs", label: "Pripada akcijam", sortable: false },
|
||||
{ key: "auto_mail", label: "Auto mail", sortable: false },
|
||||
{ key: "actions", label: "", sortable: false, class: "w-12" },
|
||||
];
|
||||
|
||||
const form = useForm({
|
||||
|
|
@ -187,8 +191,6 @@ function defaultConfigForKey(key) {
|
|||
return { archive_setting_id: null, reactivate: false };
|
||||
case "end_field_job":
|
||||
return {};
|
||||
case "add_call_later":
|
||||
return {};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
|
@ -464,12 +466,18 @@ const destroyDecision = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DataTableNew2
|
||||
<DataTableClient
|
||||
:columns="columns"
|
||||
:data="filtered"
|
||||
:pageSize="25"
|
||||
:rows="filtered"
|
||||
:sort="sort"
|
||||
:search="''"
|
||||
:page="page"
|
||||
:pageSize="pageSize"
|
||||
:showToolbar="false"
|
||||
:showPagination="true"
|
||||
@update:sort="(v) => (sort = v)"
|
||||
@update:page="(v) => (page = v)"
|
||||
@update:pageSize="(v) => (pageSize = v)"
|
||||
>
|
||||
<template #cell-color_tag="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -486,13 +494,14 @@ const destroyDecision = () => {
|
|||
</template>
|
||||
<template #cell-events="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600">{{ row.events?.length ?? 0 }}</span>
|
||||
<Dropdown align="left" width="64" :close-on-content-click="false">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200"
|
||||
>
|
||||
{{ row.events?.length ?? 0 }}
|
||||
<DottedMenu size="sm" css="text-gray-600" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
|
|
@ -540,7 +549,7 @@ const destroyDecision = () => {
|
|||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<template #actions="{ row }">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon">
|
||||
|
|
@ -563,7 +572,7 @@ const destroyDecision = () => {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
</DataTableClient>
|
||||
</div>
|
||||
<Dialog v-model:open="drawerEdit">
|
||||
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
|
|
@ -743,11 +752,6 @@ const destroyDecision = () => {
|
|||
Ta dogodek nima dodatnih nastavitev.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="eventKey(ev) === 'add_call_later'">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Datum in ura povratnega klica se vneseta ob ustvarjanju aktivnosti.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Fallback advanced editor for unknown event keys -->
|
||||
<InputLabel :for="`cfg-${idx}`" value="Napredna nastavitev (JSON)" />
|
||||
|
|
@ -977,11 +981,6 @@ const destroyDecision = () => {
|
|||
Ta dogodek nima dodatnih nastavitev.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="eventKey(ev) === 'add_call_later'">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Datum in ura povratnega klica se vneseta ob ustvarjanju aktivnosti.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<InputLabel :for="`ccfg-${idx}`" value="Napredna nastavitev (JSON)" />
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -6,13 +6,11 @@ export function fmtDateTime(d) {
|
|||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const timePart = dt.toLocaleTimeString("sl-SI", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return `${datePart} ${timePart}`;
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AccountBookingController;
|
||||
use App\Http\Controllers\AccountInstallmentController;
|
||||
use App\Http\Controllers\AccountPaymentController;
|
||||
use App\Http\Controllers\ActivityNotificationController;
|
||||
use App\Http\Controllers\ArchiveSettingController;
|
||||
|
|
@ -13,7 +12,6 @@
|
|||
use App\Http\Controllers\FieldJobSettingController;
|
||||
use App\Http\Controllers\ImportController;
|
||||
use App\Http\Controllers\ImportTemplateController;
|
||||
use App\Http\Controllers\InstallmentSettingController;
|
||||
use App\Http\Controllers\NotificationController;
|
||||
use App\Http\Controllers\PaymentSettingController;
|
||||
use App\Http\Controllers\PersonController;
|
||||
|
|
@ -159,19 +157,18 @@
|
|||
Route::get('sms-logs', [\App\Http\Controllers\Admin\SmsLogController::class, 'index'])->name('sms-logs.index');
|
||||
Route::get('sms-logs/{smsLog}', [\App\Http\Controllers\Admin\SmsLogController::class, 'show'])->name('sms-logs.show');
|
||||
|
||||
});
|
||||
// Packages (batch jobs)
|
||||
Route::get('packages', [\App\Http\Controllers\Admin\PackageController::class, 'index'])->name('packages.index');
|
||||
Route::get('packages/create', [\App\Http\Controllers\Admin\PackageController::class, 'create'])->name('packages.create');
|
||||
Route::get('packages/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'show'])->name('packages.show');
|
||||
Route::post('packages', [\App\Http\Controllers\Admin\PackageController::class, 'store'])->name('packages.store');
|
||||
Route::post('packages/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('packages.dispatch');
|
||||
Route::post('packages/{package}/cancel', [\App\Http\Controllers\Admin\PackageController::class, 'cancel'])->name('packages.cancel');
|
||||
Route::delete('packages/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'destroy'])->name('packages.destroy');
|
||||
// Packages - contract-based helpers
|
||||
Route::get('packages-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('packages.contracts');
|
||||
Route::post('packages-from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('packages.store-from-contracts');
|
||||
|
||||
// Packages (SMS batch sender) — accessible to users with manage-settings permission
|
||||
Route::middleware(['permission:manage-settings'])->prefix('packages')->name('packages.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Admin\PackageController::class, 'index'])->name('index');
|
||||
Route::get('/create', [\App\Http\Controllers\Admin\PackageController::class, 'create'])->name('create');
|
||||
Route::get('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'show'])->name('show');
|
||||
Route::post('/', [\App\Http\Controllers\Admin\PackageController::class, 'store'])->name('store');
|
||||
Route::post('/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('dispatch');
|
||||
Route::post('/{package}/cancel', [\App\Http\Controllers\Admin\PackageController::class, 'cancel'])->name('cancel');
|
||||
Route::delete('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'destroy'])->name('destroy');
|
||||
Route::get('/contracts/list', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('contracts');
|
||||
Route::post('/from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('store-from-contracts');
|
||||
});
|
||||
|
||||
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
|
||||
|
|
@ -504,20 +501,12 @@
|
|||
Route::get('bookings', [AccountBookingController::class, 'index'])->name('bookings.index');
|
||||
Route::post('bookings', [AccountBookingController::class, 'store'])->name('bookings.store');
|
||||
Route::delete('bookings/{booking}', [AccountBookingController::class, 'destroy'])->name('bookings.destroy');
|
||||
|
||||
Route::get('installments/list', [AccountInstallmentController::class, 'list'])->name('installments.list');
|
||||
Route::post('installments', [AccountInstallmentController::class, 'store'])->name('installments.store');
|
||||
Route::delete('installments/{installment}', [AccountInstallmentController::class, 'destroy'])->name('installments.destroy');
|
||||
});
|
||||
|
||||
// settings - payment settings
|
||||
Route::get('settings/payment', [PaymentSettingController::class, 'edit'])->name('settings.payment.edit');
|
||||
Route::put('settings/payment', [PaymentSettingController::class, 'update'])->name('settings.payment.update');
|
||||
|
||||
// settings - installment settings
|
||||
Route::get('settings/installment', [InstallmentSettingController::class, 'edit'])->name('settings.installment.edit');
|
||||
Route::put('settings/installment', [InstallmentSettingController::class, 'update'])->name('settings.installment.update');
|
||||
|
||||
Route::get('types/address', function (Request $request) {
|
||||
$types = App\Models\Person\AddressType::all();
|
||||
|
||||
|
|
@ -537,8 +526,4 @@
|
|||
});
|
||||
Route::get('reports/{slug}/export', [\App\Http\Controllers\ReportController::class, 'export'])->middleware('permission:reports-export')->name('reports.export');
|
||||
|
||||
// Call laters
|
||||
Route::get('call-laters', [\App\Http\Controllers\CallLaterController::class, 'index'])->name('callLaters.index');
|
||||
Route::patch('call-laters/{callLater}/complete', [\App\Http\Controllers\CallLaterController::class, 'complete'])->name('callLaters.complete');
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@
|
|||
$contract3->segments()->attach($segment->id, ['active' => true]);
|
||||
|
||||
// Test without date filters - should return all contracts
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
]));
|
||||
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
expect($data)->toHaveCount(3);
|
||||
|
||||
// Test with start_date_from filter
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
'start_date_from' => '2024-02-01',
|
||||
]));
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
expect(collect($data)->pluck('reference'))->toContain('CONTRACT-2024-002', 'CONTRACT-2024-003');
|
||||
|
||||
// Test with start_date_to filter
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
'start_date_to' => '2024-03-31',
|
||||
]));
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
expect(collect($data)->pluck('reference'))->toContain('CONTRACT-2024-001', 'CONTRACT-2024-002');
|
||||
|
||||
// Test with both date filters
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
'start_date_from' => '2024-02-01',
|
||||
'start_date_to' => '2024-04-30',
|
||||
|
|
@ -133,7 +133,7 @@
|
|||
$segment = Segment::factory()->create(['active' => true]);
|
||||
|
||||
// Test invalid start_date_from
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
'start_date_from' => 'invalid-date',
|
||||
]));
|
||||
|
|
@ -142,7 +142,7 @@
|
|||
$response->assertJsonValidationErrors('start_date_from');
|
||||
|
||||
// Test invalid start_date_to
|
||||
$response = $this->getJson(route('packages.contracts', [
|
||||
$response = $this->getJson(route('admin.packages.contracts', [
|
||||
'segment_id' => $segment->id,
|
||||
'start_date_to' => 'invalid-date',
|
||||
]));
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
<?php
|
||||
|
||||
// Override the global uses() so these pure-logic tests skip RefreshDatabase
|
||||
uses(\PHPUnit\Framework\TestCase::class);
|
||||
|
||||
/**
|
||||
* Unit-level tests for the decision_ids filter logic used in AutoMailDispatcher.
|
||||
* These tests execute the filter predicate in isolation without database interaction.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simulates the filter closure from AutoMailDispatcher::maybeQueue().
|
||||
*
|
||||
* @param array<string,mixed> $preferences
|
||||
*/
|
||||
function emailPassesDecisionFilter(array $preferences, int $decisionId): bool
|
||||
{
|
||||
$decisionIds = $preferences['decision_ids'] ?? [];
|
||||
|
||||
if (empty($decisionIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($decisionId, array_map('intval', $decisionIds), true);
|
||||
}
|
||||
|
||||
it('email with no decision_ids restriction passes the filter for any decision', function () {
|
||||
expect(emailPassesDecisionFilter([], 5))->toBeTrue();
|
||||
});
|
||||
|
||||
it('email with a matching decision_id in preferences passes the filter', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 7))->toBeTrue();
|
||||
});
|
||||
|
||||
it('email with a non-matching decision_id in preferences is filtered out', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 99))->toBeFalse();
|
||||
});
|
||||
|
||||
it('email with empty preferences is treated as no restriction', function () {
|
||||
expect(emailPassesDecisionFilter([], 42))->toBeTrue();
|
||||
});
|
||||
|
||||
it('string decision ids in preferences are cast to int for comparison', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 7))->toBeTrue();
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 99))->toBeFalse();
|
||||
});
|
||||
|
|
@ -3,4 +3,3 @@
|
|||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class)->in('Feature', 'Unit');
|
||||
uses(\PHPUnit\Framework\TestCase::class)->in('Pure');
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit-level tests for the decision_ids filter logic used in AutoMailDispatcher.
|
||||
* These tests execute the filter predicate in isolation without database interaction.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simulates the filter closure from AutoMailDispatcher::maybeQueue().
|
||||
*
|
||||
* @param array<string,mixed> $preferences
|
||||
*/
|
||||
function emailPassesDecisionFilter(array $preferences, int $decisionId): bool
|
||||
{
|
||||
$decisionIds = $preferences['decision_ids'] ?? [];
|
||||
|
||||
if (empty($decisionIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($decisionId, array_map('intval', $decisionIds), true);
|
||||
}
|
||||
|
||||
it('email with no decision_ids restriction passes the filter for any decision', function () {
|
||||
expect(emailPassesDecisionFilter([], 5))->toBeTrue();
|
||||
});
|
||||
|
||||
it('email with a matching decision_id in preferences passes the filter', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 7))->toBeTrue();
|
||||
});
|
||||
|
||||
it('email with a non-matching decision_id in preferences is filtered out', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => [3, 7]], 99))->toBeFalse();
|
||||
});
|
||||
|
||||
it('email with empty preferences is treated as no restriction', function () {
|
||||
expect(emailPassesDecisionFilter([], 42))->toBeTrue();
|
||||
});
|
||||
|
||||
it('string decision ids in preferences are cast to int for comparison', function () {
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 7))->toBeTrue();
|
||||
expect(emailPassesDecisionFilter(['decision_ids' => ['3', '7']], 99))->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user