Compare commits

..

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

55 changed files with 241 additions and 2065 deletions

View File

@ -1,132 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreInstallmentRequest;
use App\Models\Account;
use App\Models\Activity;
use App\Models\Booking;
use App\Models\Installment;
use App\Models\InstallmentSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
class AccountInstallmentController extends Controller
{
public function list(Account $account): JsonResponse
{
$installments = Installment::query()
->where('account_id', $account->id)
->orderByDesc('installment_at')
->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'installment_at', 'created_at'])
->map(function (Installment $i) {
return [
'id' => $i->id,
'amount' => (float) $i->amount,
'balance_before' => (float) ($i->balance_before ?? 0),
'currency' => $i->currency,
'reference' => $i->reference,
'installment_at' => optional($i->installment_at)?->toDateString(),
'created_at' => optional($i->created_at)?->toDateTimeString(),
];
});
return response()->json([
'account' => [
'id' => $account->id,
'balance_amount' => $account->balance_amount,
],
'installments' => $installments,
]);
}
public function store(StoreInstallmentRequest $request, Account $account): RedirectResponse
{
$validated = $request->validated();
$amountCents = (int) round(((float) $validated['amount']) * 100);
$settings = InstallmentSetting::query()->first();
$defaultCurrency = strtoupper($settings->default_currency ?? 'EUR');
$installment = Installment::query()->create([
'account_id' => $account->id,
'balance_before' => (float) ($account->balance_amount ?? 0),
'amount' => (float) $validated['amount'],
'currency' => strtoupper($validated['currency'] ?? $defaultCurrency),
'reference' => $validated['reference'] ?? null,
'installment_at' => $validated['installment_at'] ?? now(),
'meta' => $validated['meta'] ?? null,
'created_by' => $request->user()?->id,
]);
// Debit booking — increases the account balance
Booking::query()->create([
'account_id' => $account->id,
'payment_id' => null,
'amount_cents' => $amountCents,
'type' => 'debit',
'description' => $installment->reference ? ('Obremenitev '.$installment->reference) : 'Obremenitev',
'booked_at' => $installment->installment_at ?? now(),
]);
if ($settings && ($settings->create_activity_on_installment ?? false)) {
$note = $settings->activity_note_template ?? 'Dodan obrok';
$note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $installment->currency], $note);
$account->refresh();
$beforeStr = number_format((float) ($installment->balance_before ?? 0), 2, ',', '.').' '.$installment->currency;
$afterStr = number_format((float) ($account->balance_amount ?? 0), 2, ',', '.').' '.$installment->currency;
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: obrok)";
$account->loadMissing('contract');
$clientCaseId = $account->contract?->client_case_id;
if ($clientCaseId) {
$activity = Activity::query()->create([
'due_date' => null,
'amount' => $amountCents / 100,
'note' => $note,
'action_id' => $settings->default_action_id,
'decision_id' => $settings->default_decision_id,
'client_case_id' => $clientCaseId,
'contract_id' => $account->contract_id,
]);
$installment->update(['activity_id' => $activity->id]);
}
}
return back()->with('success', 'Installment created.');
}
public function destroy(Account $account, Installment $installment): RedirectResponse|JsonResponse
{
if ($installment->account_id !== $account->id) {
abort(404);
}
// Delete related debit booking(s) to revert balance via model events
Booking::query()
->where('account_id', $account->id)
->where('type', 'debit')
->whereDate('booked_at', optional($installment->installment_at)?->toDateString())
->where('amount_cents', (int) round(((float) $installment->amount) * 100))
->whereNull('payment_id')
->get()
->each->delete();
if ($installment->activity_id) {
$activity = Activity::query()->find($installment->activity_id);
if ($activity) {
$activity->delete();
}
}
$installment->delete();
if (request()->wantsJson()) {
return response()->json(['success' => true]);
}
return back()->with('success', 'Installment deleted.');
}
}

View File

@ -12,6 +12,7 @@
use App\Models\SmsTemplate;
use App\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
@ -29,7 +30,7 @@ public function index(Request $request): Response
->latest('id')
->paginate($perPage);
return Inertia::render('Packages/Index', [
return Inertia::render('Admin/Packages/Index', [
'packages' => $packages,
]);
}
@ -69,7 +70,7 @@ public function create(Request $request): Response
})
->values();
return Inertia::render('Packages/Create', [
return Inertia::render('Admin/Packages/Create', [
'profiles' => $profiles,
'senders' => $senders,
'templates' => $templates,
@ -212,7 +213,7 @@ public function show(Package $package, SmsService $sms): Response
}
}
return Inertia::render('Packages/Show', [
return Inertia::render('Admin/Packages/Show', [
'package' => $package,
'items' => $items,
'preview' => $preview,

View File

@ -1,53 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\CallLater;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CallLaterController extends Controller
{
public function index(Request $request): \Inertia\Response
{
$query = CallLater::query()
->with([
'clientCase.person',
'contract',
'user',
'activity',
])
->whereNull('completed_at')
->orderBy('call_back_at', 'asc');
if ($request->filled('date_from')) {
$query->whereDate('call_back_at', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->whereDate('call_back_at', '<=', $request->date_to);
}
if ($request->filled('search')) {
$term = '%'.$request->search.'%';
$query->whereHas('clientCase.person', function ($q) use ($term) {
$q->where('first_name', 'ilike', $term)
->orWhere('last_name', 'ilike', $term)
->orWhere('full_name', 'ilike', $term)
->orWhereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", [$term]);
});
}
$callLaters = $query->paginate(50)->withQueryString();
return Inertia::render('CallLaters/Index', [
'callLaters' => $callLaters,
'filters' => $request->only(['date_from', 'date_to', 'search']),
]);
}
public function complete(CallLater $callLater): \Illuminate\Http\RedirectResponse
{
$callLater->update(['completed_at' => now()]);
return back()->with('success', 'Klic označen kot opravljen.');
}
}

View File

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

View File

@ -27,7 +27,7 @@ public function index(Client $client, Request $request)
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id');
})
// ->where('clients.active', 1)
//->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) {
@ -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(),
@ -177,7 +175,7 @@ public function exportContracts(ExportClientContractsRequest $request, Client $c
{
$data = $request->validated();
$columns = array_values(array_unique($data['columns']));
$from = $data['from'] ?? null;
$to = $data['to'] ?? null;
$search = $data['search'] ?? null;
@ -238,7 +236,7 @@ private function buildExportFilename(Client $client): string
{
$datePrefix = now()->format('dmy');
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
}

View File

@ -1,66 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateInstallmentSettingRequest;
use App\Models\Action;
use App\Models\Decision;
use App\Models\InstallmentSetting;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class InstallmentSettingController extends Controller
{
public function edit(): Response
{
$setting = InstallmentSetting::query()->first();
if (! $setting) {
$setting = InstallmentSetting::query()->create([
'default_currency' => 'EUR',
'create_activity_on_installment' => false,
'default_decision_id' => null,
'default_action_id' => null,
'activity_note_template' => 'Dodan obrok: {amount} {currency}',
]);
}
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
$actions = Action::query()
->with(['decisions:id'])
->orderBy('name')
->get()
->map(function (Action $a) {
return [
'id' => $a->id,
'name' => $a->name,
'decision_ids' => $a->decisions->pluck('id')->values(),
];
});
return Inertia::render('Settings/Installments/Index', [
'setting' => [
'id' => $setting->id,
'default_currency' => $setting->default_currency,
'create_activity_on_installment' => (bool) $setting->create_activity_on_installment,
'default_decision_id' => $setting->default_decision_id,
'default_action_id' => $setting->default_action_id,
'activity_note_template' => $setting->activity_note_template,
],
'decisions' => $decisions,
'actions' => $actions,
]);
}
public function update(UpdateInstallmentSettingRequest $request): RedirectResponse
{
$data = $request->validated();
$setting = InstallmentSetting::query()->firstOrFail();
$data['create_activity_on_installment'] = (bool) ($data['create_activity_on_installment'] ?? false);
$setting->update($data);
return back()->with('success', 'Nastavitve shranjene.');
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\BankAccount;
use App\Models\Person\Person;
use Illuminate\Http\Request;
@ -26,7 +27,9 @@ public function update(Person $person, Request $request)
$person->update($attributes);
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
}
public function createAddress(Person $person, Request $request)
@ -69,7 +72,7 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$address->update($attributes);
return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
}
public function deleteAddress(Person $person, int $address_id, Request $request)
@ -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,7 +238,8 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete();
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
}
}

View File

@ -59,15 +59,6 @@ public function share(Request $request): array
'info' => fn () => $request->session()->get('info'),
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
],
'callLaterCount' => function () use ($request) {
if (! $request->user()) {
return 0;
}
return \App\Models\CallLater::query()
->whereNull('completed_at')
->count();
},
'notifications' => function () use ($request) {
try {
$user = $request->user();

View File

@ -1,24 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreInstallmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'numeric', 'min:0.01'],
'currency' => ['nullable', 'string', 'size:3'],
'reference' => ['nullable', 'string', 'max:100'],
'installment_at' => ['nullable', 'date'],
'meta' => ['nullable', 'array'],
];
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateInstallmentSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'default_currency' => ['required', 'string', 'size:3'],
'create_activity_on_installment' => ['sometimes', 'boolean'],
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
'activity_note_template' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@ -10,10 +10,9 @@
class Account extends Model
{
use HasFactory;
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes;
use HasFactory;
protected $fillable = [
'reference',
@ -59,11 +58,6 @@ public function payments(): HasMany
return $this->hasMany(\App\Models\Payment::class);
}
public function installments(): HasMany
{
return $this->hasMany(\App\Models\Installment::class);
}
public function bookings(): HasMany
{
return $this->hasMany(\App\Models\Booking::class);

View File

@ -18,7 +18,6 @@ class Activity extends Model
protected $fillable = [
'due_date',
'call_back_at',
'amount',
'note',
'action_id',
@ -28,13 +27,6 @@ class Activity extends Model
'client_case_id',
];
/*protected function casts(): array
{
return [
'call_back_at' => 'datetime',
];
}*/
protected $hidden = [
'action_id',
'decision_id',
@ -154,9 +146,4 @@ public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class);
}
public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(\App\Models\CallLater::class);
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CallLater extends Model
{
protected $fillable = [
'activity_id',
'client_case_id',
'contract_id',
'user_id',
'call_back_at',
'completed_at',
];
protected function casts(): array
{
return [
'call_back_at' => 'datetime',
'completed_at' => 'datetime',
];
}
public function activity(): BelongsTo
{
return $this->belongsTo(Activity::class);
}
public function clientCase(): BelongsTo
{
return $this->belongsTo(ClientCase::class);
}
public function contract(): BelongsTo
{
return $this->belongsTo(Contract::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Installment extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = [
'account_id',
'amount',
'balance_before',
'currency',
'reference',
'installment_at',
'meta',
'created_by',
'activity_id',
];
protected function casts(): array
{
return [
'installment_at' => 'datetime',
'meta' => 'array',
'amount' => 'decimal:4',
'balance_before' => 'decimal:4',
];
}
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
public function activity(): BelongsTo
{
return $this->belongsTo(Activity::class);
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class InstallmentSetting extends Model
{
use HasFactory;
protected $fillable = [
'default_currency',
'create_activity_on_installment',
'default_decision_id',
'default_action_id',
'activity_note_template',
];
}

View File

@ -59,23 +59,10 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
// Resolve eligible recipients: client's person emails with receive_auto_mails = true
$recipients = [];
if ($client && $client->person) {
$emails = Email::query()
$recipients = Email::query()
->where('person_id', $client->person->id)
->where('is_active', true)
->where('receive_auto_mails', true)
->get(['value', 'preferences']);
$recipients = $emails
->filter(function (Email $email) use ($decision): bool {
$decisionIds = $email->preferences['decision_ids'] ?? [];
// Empty list means "all decisions" — always receive
if (empty($decisionIds)) {
return true;
}
return in_array((int) $decision->id, array_map('intval', $decisionIds), true);
})
->pluck('value')
->map(fn ($v) => strtolower(trim((string) $v)))
->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))

View File

@ -11,9 +11,9 @@
class ClientCaseDataService
{
/**
* Get contracts for a client case with optional segment filtering.
* Get paginated contracts for a client case with optional segment filtering.
*/
public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Collection
public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int $perPage = 50): LengthAwarePaginator
{
$query = $clientCase->contracts()
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
@ -40,8 +40,9 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Co
$query->forSegment($segmentId);
}
$perPage = max(1, min(100, $perPage));
return $query->get();
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
}
/**

View File

@ -1,27 +0,0 @@
<?php
namespace App\Services\DecisionEvents\Handlers;
use App\Models\CallLater;
use App\Services\DecisionEvents\Contracts\DecisionEventHandler;
use App\Services\DecisionEvents\DecisionEventContext;
class CallLaterHandler implements DecisionEventHandler
{
public function handle(DecisionEventContext $context, array $config = []): void
{
$activity = $context->activity;
if (empty($activity->call_back_at)) {
return;
}
CallLater::create([
'activity_id' => $activity->id,
'client_case_id' => $activity->client_case_id,
'contract_id' => $activity->contract_id,
'user_id' => $activity->user_id,
'call_back_at' => $activity->call_back_at,
]);
}
}

View File

@ -17,19 +17,15 @@ class Registry
'add_segment' => AddSegmentHandler::class,
'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class,
'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class,
'add_call_later' => \App\Services\DecisionEvents\Handlers\CallLaterHandler::class,
];
public static function resolve(string $key): DecisionEventHandler
{
$key = trim(strtolower($key));
$class = static::$map[$key] ?? null;
if (! $class) {
if (! $class || ! class_exists($class)) {
throw new InvalidArgumentException("Unknown decision event handler for key: {$key}");
}
if (! class_exists($class)) {
throw new InvalidArgumentException("Handler class {$class} for key {$key} does not exist (check autoload)");
}
$handler = app($class);
if (! $handler instanceof DecisionEventHandler) {
throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler");

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('activities', function (Blueprint $table) {
$table->dateTime('call_back_at')->nullable()->after('due_date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('activities', function (Blueprint $table) {
$table->dropColumn('call_back_at');
});
}
};

View File

@ -1,33 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('call_laters', function (Blueprint $table) {
$table->id();
$table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete();
$table->foreignId('client_case_id')->constrained('client_cases')->cascadeOnDelete();
$table->foreignId('contract_id')->nullable()->constrained('contracts')->nullOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->dateTime('call_back_at');
$table->dateTime('completed_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('call_laters');
}
};

View File

@ -1,31 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('installments', function (Blueprint $table): void {
$table->id();
$table->foreignId('account_id')->constrained('accounts')->cascadeOnDelete();
$table->decimal('amount', 20, 4);
$table->decimal('balance_before', 20, 4)->nullable();
$table->string('currency', 3)->default('EUR');
$table->string('reference', 100)->nullable();
$table->timestamp('installment_at')->nullable();
$table->json('meta')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('activity_id')->nullable()->constrained('activities')->nullOnDelete();
$table->softDeletes();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('installments');
}
};

View File

@ -1,26 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('installment_settings', function (Blueprint $table): void {
$table->id();
$table->string('default_currency', 3)->default('EUR');
$table->boolean('create_activity_on_installment')->default(false);
$table->foreignId('default_decision_id')->nullable()->constrained('decisions')->nullOnDelete();
$table->foreignId('default_action_id')->nullable()->constrained('actions')->nullOnDelete();
$table->string('activity_note_template', 255)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('installment_settings');
}
};

View File

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

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

View File

@ -11,9 +11,6 @@
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<testsuite name="Pure">
<directory>tests/Pure</directory>
</testsuite>
</testsuites>
<source>
<include>

View File

@ -11,7 +11,7 @@ import {
} from "@tanstack/vue-table";
import { valueUpdater } from "@/lib/utils";
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
import DataTablePaginationClient from "./DataTablePaginationClient.vue";
import DataTablePagination from "./DataTablePagination.vue";
import DataTableViewOptions from "./DataTableViewOptions.vue";
import DataTableToolbar from "./DataTableToolbar.vue";
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
@ -618,14 +618,7 @@ defineExpose({
<!-- Client-side pagination -->
<template v-else>
<DataTablePaginationClient
:current-page="table.getState().pagination.pageIndex"
:last-page="table.getPageCount()"
:total="table.getFilteredRowModel().rows.length"
:showing-from="table.getFilteredSelectedRowModel().rows.length"
:showing-to="table.getFilteredRowModel().rows.length"
:table="table"
/>
<DataTablePagination :table="table" />
</template>
</div>
</div>

View File

@ -23,7 +23,6 @@ const props = defineProps({
showGoto: { type: Boolean, default: true },
maxPageLinks: { type: Number, default: 5 },
perPage: { type: Number, default: 10 },
table: { type: Object, required: true },
});
const emit = defineEmits(["update:page"]);
@ -35,7 +34,7 @@ function goToPageInput() {
const n = Number(raw);
if (!Number.isFinite(n)) return;
const target = Math.max(1, Math.min(props.lastPage, Math.floor(n)));
if (target !== props.currentPage) props.table.setPageIndex(target - 1);
if (target !== props.currentPage) setPage(target);
gotoInput.value = "";
}
@ -137,17 +136,14 @@ function setPage(p) {
>
<PaginationContent>
<!-- First -->
<PaginationFirst
:disabled="!table.getCanPreviousPage()"
@click="table.setPageIndex(0)"
>
<PaginationFirst :disabled="currentPage <= 1" @click="setPage(1)">
<ChevronsLeft />
</PaginationFirst>
<!-- Previous -->
<PaginationPrevious
:disabled="!table.getCanPreviousPage()"
@click="table.previousPage()"
:disabled="currentPage <= 1"
@click="setPage(currentPage - 1)"
>
<ChevronLeft />
</PaginationPrevious>
@ -158,22 +154,25 @@ function setPage(p) {
<PaginationItem
v-else
:value="item"
:is-active="currentPage === index"
@click="table.setPageIndex(index)"
:is-active="currentPage === item"
@click="setPage(item)"
>
{{ item }}
</PaginationItem>
</template>
<!-- Next -->
<PaginationNext :disabled="!table.getCanNextPage()" @click="table.nextPage()">
<PaginationNext
:disabled="currentPage >= lastPage"
@click="setPage(currentPage + 1)"
>
<ChevronRight />
</PaginationNext>
<!-- Last -->
<PaginationLast
:disabled="!table.getCanNextPage()"
@click="table.setPageIndex(table.getPageCount() - 1)"
:disabled="currentPage >= lastPage"
@click="setPage(lastPage)"
>
<ChevronsRight />
</PaginationLast>
@ -192,7 +191,7 @@ function setPage(p) {
:max="lastPage"
inputmode="numeric"
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
:placeholder="String(currentPage + 1)"
:placeholder="String(currentPage)"
aria-label="Pojdi na stran"
@keyup.enter="goToPageInput"
@blur="goToPageInput"

View File

@ -2,7 +2,11 @@
import { computed, ref, useAttrs } from "vue";
import { Button } from "@/Components/ui/button";
import { Calendar } from "@/Components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/Components/ui/popover";
import { cn } from "@/lib/utils";
import { CalendarIcon } from "lucide-vue-next";
import { format } from "date-fns";
@ -82,9 +86,7 @@ const toCalendarDate = (value) => {
// Convert CalendarDate to ISO string (YYYY-MM-DD)
const fromCalendarDate = (calendarDate) => {
if (!calendarDate) return null;
return `${String(calendarDate.year).padStart(4, "0")}-${String(
calendarDate.month
).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
return `${String(calendarDate.year).padStart(4, "0")}-${String(calendarDate.month).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
};
const calendarDate = computed({
@ -140,10 +142,11 @@ const open = ref(false);
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<Calendar locale="si-SI" v-model="calendarDate" :disabled="disabled" />
<Calendar v-model="calendarDate" :disabled="disabled" />
</PopoverContent>
</Popover>
<p v-if="error" class="mt-1 text-sm text-red-600">
{{ Array.isArray(error) ? error[0] : error }}
</p>
</template>

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, computed, watch, onUnmounted } from "vue";
import { ref, computed, watch } from "vue";
import {
Dialog,
DialogContent,
@ -9,7 +9,7 @@ import {
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Badge } from "../ui/badge";
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
import { Loader2 } from "lucide-vue-next";
import axios from "axios";
const props = defineProps({
@ -26,141 +26,6 @@ const loading = ref(false);
const previewGenerating = ref(false);
const previewError = ref("");
// Image viewer zoom & pan state
const containerRef = ref(null);
const imageRef = ref(null);
const imageScale = ref(1);
const translateX = ref(0);
const translateY = ref(0);
const fitScale = ref(1);
const isDragging = ref(false);
const hasMoved = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const dragStartTX = ref(0);
const dragStartTY = ref(0);
const MAX_SCALE = 8;
const imageCursorClass = computed(() => {
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
return "cursor-default";
});
const initImageView = () => {
const container = containerRef.value;
const img = imageRef.value;
if (!container || !img) return;
const cW = container.clientWidth;
const cH = container.clientHeight;
const iW = img.naturalWidth || cW;
const iH = img.naturalHeight || cH;
const fs = Math.min(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"
/>
<!-- Zoom level badge -->
<div
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
>
{{ Math.round(imageScale * 100) }}%
</div>
<!-- Reset button -->
<Button
v-if="imageScale > fitScale + 0.01"
size="icon-sm"
variant="secondary"
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
title="Ponastavi pogled"
@click.stop="resetImageView"
>
<RotateCcwIcon class="h-3 w-3" />
</Button>
<!-- Hint -->
<div
v-if="imageScale <= fitScale + 0.01"
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
>
Kolesce za povečavo / pomanjšavo · Povleči za premik
</div>
</div>
<img
:src="props.src"
:alt="props.title"
class="max-w-full max-h-full mx-auto object-contain"
/>
</template>
<!-- Text/CSV/XML Viewer -->

View File

@ -3,8 +3,7 @@ import { computed, ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router, usePage } from "@inertiajs/vue3";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
@ -28,22 +27,12 @@ const props = defineProps({
const emit = defineEmits(["close"]);
// Decisions with auto_mail = true from shared Inertia data
const page = usePage();
const decisionOptions = computed(() =>
(page.props.auto_mail_decisions ?? []).map((d) => ({
value: String(d.id),
label: d.name,
}))
);
// Zod schema for form validation
const formSchema = toTypedSchema(
z.object({
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
label: z.string().optional(),
receive_auto_mails: z.boolean().optional(),
decision_ids: z.array(z.string()).optional().default([]),
})
);
@ -54,13 +43,9 @@ const form = useForm({
value: "",
label: "",
receive_auto_mails: false,
decision_ids: [],
},
});
// Whether to limit sending to specific decisions (UI-only toggle)
const limitToDecisions = ref(false);
const processing = ref(false);
const close = () => {
@ -72,44 +57,22 @@ const close = () => {
};
const resetForm = () => {
limitToDecisions.value = false;
form.resetForm({
values: {
value: "",
label: "",
receive_auto_mails: false,
decision_ids: [],
},
});
};
// When auto mails is disabled, collapse the decision filter
watch(
() => form.values.receive_auto_mails,
(val) => {
if (!val) {
limitToDecisions.value = false;
}
}
);
// When limit toggle is turned off, clear the selection
watch(limitToDecisions, (val) => {
if (!val) {
form.setFieldValue("decision_ids", []);
}
});
const create = async () => {
processing.value = true;
const payload = {
...form.values,
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
};
const { values } = form;
router.post(
route("person.email.create", props.person),
payload,
values,
{
preserveScroll: true,
onSuccess: () => {
@ -135,14 +98,11 @@ const create = async () => {
const update = async () => {
processing.value = true;
const payload = {
...form.values,
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
};
const { values } = form;
router.put(
route("person.email.update", { person: props.person, email_id: props.id }),
payload,
values,
{
preserveScroll: true,
onSuccess: () => {
@ -176,13 +136,10 @@ watch(
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
const email = list.find((e) => e.id === props.id);
if (email) {
const existingDecisionIds = (email.preferences?.decision_ids ?? []).map(String);
limitToDecisions.value = existingDecisionIds.length > 0;
form.setValues({
value: email.value ?? email.email ?? email.address ?? "",
label: email.label ?? "",
receive_auto_mails: !!email.receive_auto_mails,
decision_ids: existingDecisionIds,
});
} else {
resetForm();
@ -271,36 +228,6 @@ const onConfirm = () => {
</div>
</FormItem>
</FormField>
<!-- Limit to specific decisions only shown when receive_auto_mails is on and decisions exist -->
<template v-if="(props.person?.client || isClientContext) && form.values.receive_auto_mails && decisionOptions.length > 0">
<div class="flex flex-row items-start space-x-3 space-y-0">
<Switch
:model-value="limitToDecisions"
@update:model-value="(val) => (limitToDecisions = val)"
/>
<div class="space-y-1 leading-none">
<label class="text-sm font-medium leading-none cursor-pointer" @click="limitToDecisions = !limitToDecisions">
Omeji na posamezne odločitve
</label>
</div>
</div>
<FormField v-if="limitToDecisions" v-slot="{ value, handleChange }" name="decision_ids">
<FormItem>
<FormLabel>Odločitve, za katere se pošlje e-pošta</FormLabel>
<FormControl>
<AppMultiSelect
:model-value="value ?? []"
:items="decisionOptions"
placeholder="Izberi odločitve..."
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</template>
</div>
</form>
</component>

View File

@ -15,6 +15,7 @@ import {
InboxIcon,
AtSignIcon,
BookUserIcon,
MessageSquareIcon,
ArrowLeftIcon,
} from "lucide-vue-next";
import Dropdown from "@/Components/Dropdown.vue";
@ -210,6 +211,13 @@ const navGroups = computed(() => [
icon: Settings2Icon,
active: ["admin.sms-profiles.index"],
},
{
key: "admin.packages.index",
label: "SMS paketi",
route: "admin.packages.index",
icon: MessageSquareIcon,
active: ["admin.packages.index", "admin.packages.show"],
},
],
},
]);

View File

@ -26,9 +26,6 @@ import { SettingsIcon } from "lucide-vue-next";
import { ShieldUserIcon } from "lucide-vue-next";
import { SmartphoneIcon } from "lucide-vue-next";
import { TabletSmartphoneIcon } from "lucide-vue-next";
import { PhoneCallIcon } from "lucide-vue-next";
import { PackageIcon } from "lucide-vue-next";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
title: String,
@ -160,13 +157,6 @@ const rawMenuGroups = [
routeName: "segments.index",
active: ["segments.index"],
},
{
key: "call-laters",
icon: PhoneCallIcon,
title: "Pokliči kasneje",
routeName: "callLaters.index",
active: ["callLaters.index"],
},
],
},
{
@ -222,13 +212,6 @@ const rawMenuGroups = [
routeName: "settings",
active: ["settings", "settings.*"],
},
{
key: "packages",
icon: PackageIcon,
title: "SMS paketi",
routeName: "packages.index",
active: ["packages.index", "packages.show", "packages.create"],
},
// Admin panel (roles & permissions management)
// Only shown if current user has admin role or manage-settings permission.
// We'll filter it out below if not authorized.
@ -285,14 +268,6 @@ function isActive(patterns) {
return false;
}
}
function getBadge(item) {
if (item.key === "call-laters") {
return page.props.callLaterCount || 0;
}
return 0;
}
</script>
<template>
@ -366,18 +341,11 @@ function getBadge(item) {
<!-- Title -->
<span
v-if="!sidebarCollapsed"
class="flex-1 truncate transition-opacity"
class="truncate transition-opacity"
:class="{ 'font-medium': isActive(item.active) }"
>
{{ item.title }}
</span>
<Badge
v-if="!sidebarCollapsed && getBadge(item) > 0"
variant="destructive"
class="ml-auto shrink-0 px-1.5 py-0.5 text-xs font-mono text-amber-50"
>
{{ getBadge(item) }}
</Badge>
</Link>
</li>
</ul>

View File

@ -107,6 +107,12 @@ const cards = [
route: "admin.sms-logs.index",
icon: InboxIcon,
},
{
title: "SMS paketi",
description: "Kreiranje in pošiljanje serijskih SMS paketov",
route: "admin.packages.index",
icon: MessageSquareIcon,
},
],
},
];

View File

@ -1,5 +1,5 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, router, useForm } from "@inertiajs/vue3";
import { ref, computed, nextTick } from "vue";
import axios from "axios";
@ -112,9 +112,9 @@ function submitCreate() {
})),
};
router.post(route("packages.store"), payload, {
router.post(route("admin.packages.store"), payload, {
onSuccess: () => {
router.visit(route("packages.index"));
router.visit(route("admin.packages.index"));
},
});
}
@ -202,7 +202,7 @@ async function loadContracts(url = null) {
if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
const target = url || `${route("packages.contracts")}?${params.toString()}`;
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
const { data: json } = await axios.get(target, {
headers: { "X-Requested-With": "XMLHttpRequest" },
});
@ -268,7 +268,7 @@ function goToPage(page) {
params.append("per_page", perPage.value);
params.append("page", page);
const url = `${route("packages.contracts")}?${params.toString()}`;
const url = `${route("admin.packages.contracts")}?${params.toString()}`;
loadContracts(url);
}
@ -312,9 +312,9 @@ function submitCreateFromContracts() {
};
creatingFromContracts.value = true;
router.post(route("packages.store-from-contracts"), payload, {
router.post(route("admin.packages.store-from-contracts"), payload, {
onSuccess: () => {
router.visit(route("packages.index"));
router.visit(route("admin.packages.index"));
},
onError: (errors) => {
const first = errors && Object.values(errors)[0];
@ -337,11 +337,11 @@ const numbersCount = computed(() => {
</script>
<template>
<AppLayout title="Ustvari SMS paket">
<AdminLayout title="Ustvari SMS paket">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<Link :href="route('packages.index')">
<Link :href="route('admin.packages.index')">
<Button variant="ghost" size="sm">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
@ -520,7 +520,7 @@ const numbersCount = computed(() => {
</div>
<div class="flex justify-end gap-2">
<Button
@click="router.visit(route('packages.index'))"
@click="router.visit(route('admin.packages.index'))"
variant="outline"
>
Prekliči
@ -703,7 +703,7 @@ const numbersCount = computed(() => {
Izbrano: {{ selectedContractIds.size }}
</Badge>
<Button
@click="router.visit(route('packages.index'))"
@click="router.visit(route('admin.packages.index'))"
variant="outline"
>
Prekliči
@ -806,5 +806,5 @@ const numbersCount = computed(() => {
</Card>
</TabsContent>
</Tabs>
</AppLayout>
</AdminLayout>
</template>

View File

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

View File

@ -1,5 +1,5 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { onMounted, onUnmounted, ref, computed } from "vue";
import {
@ -88,14 +88,14 @@ function reload() {
function dispatchPkg() {
router.post(
route("packages.dispatch", props.package.id),
route("admin.packages.dispatch", props.package.id),
{},
{ onSuccess: reload }
);
}
function cancelPkg() {
router.post(
route("packages.cancel", props.package.id),
route("admin.packages.cancel", props.package.id),
{},
{ onSuccess: reload }
);
@ -132,7 +132,7 @@ async function copyText(text) {
</script>
<template>
<AppLayout :title="`Paket #${package.id}`">
<AdminLayout :title="`Paket #${package.id}`">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
@ -147,7 +147,7 @@ async function copyText(text) {
</div>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" as-child>
<Link :href="route('packages.index')">
<Link :href="route('admin.packages.index')">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
</Link>
@ -281,7 +281,7 @@ async function copyText(text) {
:columns="columns"
:data="items.data"
:meta="items"
route-name="packages.show"
route-name="admin.packages.show"
:route-params="{ id: package.id }"
>
<template #cell-target="{ row }">
@ -333,5 +333,5 @@ async function copyText(text) {
<div v-if="refreshing" class="mt-2 text-xs text-muted-foreground">
Osveževanje ...
</div>
</AppLayout>
</AdminLayout>
</template>

View File

@ -2,13 +2,7 @@
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { useForm, Link } from "@inertiajs/vue3";
import { KeyRoundIcon, ArrowLeftIcon, SaveIcon } from "lucide-vue-next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
@ -42,16 +36,12 @@ function submit() {
<CardHeader>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3">
<div
class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"
>
<div class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary">
<KeyRoundIcon class="h-5 w-5" />
</div>
<div>
<CardTitle>Uredi dovoljenje</CardTitle>
<CardDescription
>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription
>
<CardDescription>Posodobi sistemsko dovoljenje in pripete vloge.</CardDescription>
</div>
</div>
<Button variant="ghost" size="sm" as-child>
@ -63,6 +53,7 @@ function submit() {
</div>
</CardHeader>
<CardContent>
<form @submit.prevent="submit" class="space-y-6">
<div class="grid sm:grid-cols-2 gap-6">
<div class="space-y-2">
@ -95,19 +86,16 @@ function submit() {
class="flex items-center gap-2 text-sm cursor-pointer"
>
<Checkbox
:default-value="form.roles.includes(r.id)"
@update:model-value="
(checked) => {
if (checked) form.roles.push(r.id);
else form.roles = form.roles.filter((id) => id !== r.id);
}
"
:value="r.id"
:checked="form.roles.includes(r.id)"
@update:checked="(checked) => {
if (checked) form.roles.push(r.id)
else form.roles = form.roles.filter(id => id !== r.id)
}"
/>
<span
><span class="font-medium">{{ r.name }}</span>
<span class="text-xs text-muted-foreground"
>({{ r.slug }})</span
></span
<span class="text-xs text-muted-foreground">({{ r.slug }})</span></span
>
</label>
</div>

View File

@ -1,288 +0,0 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { computed, ref } from "vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import InputLabel from "@/Components/InputLabel.vue";
import Pagination from "@/Components/Pagination.vue";
import {
PhoneCallIcon,
CheckIcon,
Filter,
ExternalLinkIcon,
MoreHorizontalIcon,
} from "lucide-vue-next";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({
callLaters: Object,
filters: Object,
});
const search = ref(props.filters?.search || "");
const dateFrom = ref(props.filters?.date_from || "");
const dateTo = ref(props.filters?.date_to || "");
const filterPopoverOpen = ref(false);
const appliedFilterCount = computed(() => {
let count = 0;
if (search.value?.trim()) count += 1;
if (dateFrom.value) count += 1;
if (dateTo.value) count += 1;
return count;
});
function applyFilters() {
filterPopoverOpen.value = false;
const params = {};
if (search.value?.trim()) {
params.search = search.value.trim();
}
if (dateFrom.value) {
params.date_from = dateFrom.value;
}
if (dateTo.value) {
params.date_to = dateTo.value;
}
router.get(route("callLaters.index"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}
function clearFilters() {
search.value = "";
dateFrom.value = "";
dateTo.value = "";
applyFilters();
}
function markDone(item) {
router.patch(
route("callLaters.complete", item.id),
{},
{
preserveScroll: true,
}
);
}
function openAndComplete(item) {
router.patch(
route("callLaters.complete", item.id),
{},
{
preserveScroll: false,
onSuccess: () => {
if (item.client_case?.uuid) {
router.visit(route("clientCase.show", { client_case: item.client_case.uuid }));
}
},
}
);
}
function isOverdue(item) {
if (!item.call_back_at) return false;
// Strip Z so the value is parsed as local time (datetimes are stored as local time with a wrong Z suffix)
const localDateStr = item.call_back_at.replace("Z", "").replace("T", " ");
return new Date(localDateStr) < new Date();
}
const columns = [
{ key: "person", label: "Stranka / Primer", sortable: false },
{ key: "contract", label: "Pogodba", sortable: false },
{ key: "call_back_at", label: "Datum klica", sortable: false },
{ key: "user", label: "Agent", sortable: false },
{ key: "note", label: "Opomba", sortable: false },
{ key: "actions", label: "", sortable: false, class: "w-12" },
];
</script>
<template>
<AppLayout title="Pokliči kasneje">
<template #header></template>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<PhoneCallIcon :size="18" />
<CardTitle class="uppercase">Pokliči kasneje</CardTitle>
</div>
</template>
<DataTable
:columns="columns"
:data="callLaters.data || []"
:meta="callLaters"
:search="search"
route-name="callLaters.index"
:show-toolbar="true"
:show-pagination="false"
:hoverable="true"
row-key="id"
empty-text="Ni zakazanih klicev."
:row-class="(row) => (isOverdue(row) ? 'bg-red-50 dark:bg-red-950/20' : '')"
>
<template #toolbar-filters>
<AppPopover
v-model:open="filterPopoverOpen"
align="start"
content-class="w-[420px]"
>
<template #trigger>
<Button variant="outline" size="sm" class="gap-2">
<Filter class="h-4 w-4" />
Filtri
<span
v-if="appliedFilterCount > 0"
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
>
{{ appliedFilterCount }}
</span>
</Button>
</template>
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Filtri klicev</h4>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<InputLabel>Iskanje (stranka)</InputLabel>
<Input
v-model="search"
type="text"
placeholder="Ime stranke..."
@keydown.enter="applyFilters"
/>
</div>
<div class="space-y-1.5">
<InputLabel>Datum od</InputLabel>
<Input v-model="dateFrom" type="date" />
</div>
<div class="space-y-1.5">
<InputLabel>Datum do</InputLabel>
<Input v-model="dateTo" type="date" />
</div>
<div class="flex justify-end gap-2 pt-2 border-t">
<Button
type="button"
variant="outline"
size="sm"
:disabled="appliedFilterCount === 0"
@click="clearFilters"
>
Počisti
</Button>
<Button type="button" size="sm" @click="applyFilters">
Uporabi
</Button>
</div>
</div>
</div>
</AppPopover>
</template>
<template #cell-person="{ row }">
<div>
<Link
v-if="row.client_case"
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
class="font-medium text-indigo-600 hover:underline"
>
{{ row.client_case.person?.full_name || "-" }}
</Link>
<span v-else class="text-muted-foreground">-</span>
</div>
</template>
<template #cell-contract="{ row }">
<span v-if="row.contract">{{ row.contract.reference }}</span>
<span v-else class="text-muted-foreground">-</span>
</template>
<template #cell-call_back_at="{ row }">
<span
:class="[
'font-medium',
isOverdue(row) ? 'text-red-600 dark:text-red-400' : '',
]"
>
{{ fmtDateTime(row.call_back_at) }}
</span>
<span v-if="isOverdue(row)" class="ml-2 text-xs text-red-500 font-semibold">
Zamuda
</span>
</template>
<template #cell-user="{ row }">
<span v-if="row.user">{{ row.user.name }}</span>
<span v-else class="text-muted-foreground">-</span>
</template>
<template #cell-note="{ row }">
<span class="line-clamp-2 text-sm text-muted-foreground">
{{ row.activity?.note || "-" }}
</span>
</template>
<template #cell-actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="icon" variant="ghost" class="h-8 w-8">
<MoreHorizontalIcon class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="markDone(row)">
<CheckIcon class="mr-2 h-4 w-4" />
Opravljeno
</DropdownMenuItem>
<DropdownMenuItem
v-if="row.client_case?.uuid"
@click="openAndComplete(row)"
>
<ExternalLinkIcon class="mr-2 h-4 w-4" />
Odpri in opravi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTable>
<div class="border-t border-gray-200 p-4">
<Pagination
:links="callLaters.links"
:from="callLaters.from"
:to="callLaters.to"
:total="callLaters.total"
:per-page="callLaters.per_page || 50"
:last-page="callLaters.last_page"
:current-page="callLaters.current_page"
/>
</div>
</AppCard>
</div>
</div>
</AppLayout>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
<script setup>
import { DottedMenu } from "@/Utilities/Icons";
// flowbite-vue table imports removed; using DataTableClient
import { EditIcon, TrashBinIcon, DottedMenu } from "@/Utilities/Icons";
import {
Dialog,
DialogContent,
@ -29,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

View File

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

View File

@ -1,7 +1,6 @@
<?php
use App\Http\Controllers\AccountBookingController;
use App\Http\Controllers\AccountInstallmentController;
use App\Http\Controllers\AccountPaymentController;
use App\Http\Controllers\ActivityNotificationController;
use App\Http\Controllers\ArchiveSettingController;
@ -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');
});

View File

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

View File

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

View File

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

View File

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