diff --git a/app/Http/Controllers/AccountInstallmentController.php b/app/Http/Controllers/AccountInstallmentController.php new file mode 100644 index 0000000..7d85fd8 --- /dev/null +++ b/app/Http/Controllers/AccountInstallmentController.php @@ -0,0 +1,132 @@ +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.'); + } +} diff --git a/app/Http/Controllers/InstallmentSettingController.php b/app/Http/Controllers/InstallmentSettingController.php new file mode 100644 index 0000000..efaa5a2 --- /dev/null +++ b/app/Http/Controllers/InstallmentSettingController.php @@ -0,0 +1,66 @@ +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.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 78e2dac..e121332 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -59,6 +59,15 @@ 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(); diff --git a/app/Http/Requests/StoreInstallmentRequest.php b/app/Http/Requests/StoreInstallmentRequest.php new file mode 100644 index 0000000..71482a1 --- /dev/null +++ b/app/Http/Requests/StoreInstallmentRequest.php @@ -0,0 +1,24 @@ + ['required', 'numeric', 'min:0.01'], + 'currency' => ['nullable', 'string', 'size:3'], + 'reference' => ['nullable', 'string', 'max:100'], + 'installment_at' => ['nullable', 'date'], + 'meta' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/UpdateInstallmentSettingRequest.php b/app/Http/Requests/UpdateInstallmentSettingRequest.php new file mode 100644 index 0000000..71d169a --- /dev/null +++ b/app/Http/Requests/UpdateInstallmentSettingRequest.php @@ -0,0 +1,24 @@ + ['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'], + ]; + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index 31f542e..a2fe94d 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -10,9 +10,10 @@ class Account extends Model { + use HasFactory; + /** @use HasFactory<\Database\Factories\Person/AccountFactory> */ use SoftDeletes; - use HasFactory; protected $fillable = [ 'reference', @@ -58,6 +59,11 @@ 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); diff --git a/app/Models/Installment.php b/app/Models/Installment.php new file mode 100644 index 0000000..35a491e --- /dev/null +++ b/app/Models/Installment.php @@ -0,0 +1,46 @@ + '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); + } +} diff --git a/app/Models/InstallmentSetting.php b/app/Models/InstallmentSetting.php new file mode 100644 index 0000000..f75a932 --- /dev/null +++ b/app/Models/InstallmentSetting.php @@ -0,0 +1,19 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_11_100001_create_installment_settings_table.php b/database/migrations/2026_03_11_100001_create_installment_settings_table.php new file mode 100644 index 0000000..b2638ea --- /dev/null +++ b/database/migrations/2026_03_11_100001_create_installment_settings_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index ee6212a..34a281a 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -28,6 +28,7 @@ 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, @@ -284,6 +285,14 @@ function isActive(patterns) { return false; } } + +function getBadge(item) { + if (item.key === "call-laters") { + return page.props.callLaterCount || 0; + } + + return 0; +}