From 9c6878d1bd2fa5dc8afa5203ef9e8e63870595f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Wed, 11 Mar 2026 21:04:20 +0100 Subject: [PATCH] Option to add installment to contract/account to increace balance amount same as payment and can be deleted which will reduce balance amount by new amount of the installment deleted, call later added badge to show active call laters --- .../AccountInstallmentController.php | 132 ++++++++++++++ .../InstallmentSettingController.php | 66 +++++++ app/Http/Middleware/HandleInertiaRequests.php | 9 + app/Http/Requests/StoreInstallmentRequest.php | 24 +++ .../UpdateInstallmentSettingRequest.php | 24 +++ app/Models/Account.php | 8 +- app/Models/Installment.php | 46 +++++ app/Models/InstallmentSetting.php | 19 ++ ...03_11_100000_create_installments_table.php | 31 ++++ ...0001_create_installment_settings_table.php | 26 +++ resources/js/Layouts/AppLayout.vue | 18 +- .../js/Pages/Cases/Partials/ContractTable.vue | 83 +++++++++ .../Cases/Partials/InstallmentDialog.vue | 82 +++++++++ .../Cases/Partials/ViewInstallmentsDialog.vue | 160 +++++++++++++++++ resources/js/Pages/Settings/Index.vue | 7 + .../js/Pages/Settings/Installments/Index.vue | 167 ++++++++++++++++++ routes/web.php | 10 ++ 17 files changed, 910 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/AccountInstallmentController.php create mode 100644 app/Http/Controllers/InstallmentSettingController.php create mode 100644 app/Http/Requests/StoreInstallmentRequest.php create mode 100644 app/Http/Requests/UpdateInstallmentSettingRequest.php create mode 100644 app/Models/Installment.php create mode 100644 app/Models/InstallmentSetting.php create mode 100644 database/migrations/2026_03_11_100000_create_installments_table.php create mode 100644 database/migrations/2026_03_11_100001_create_installment_settings_table.php create mode 100644 resources/js/Pages/Cases/Partials/InstallmentDialog.vue create mode 100644 resources/js/Pages/Cases/Partials/ViewInstallmentsDialog.vue create mode 100644 resources/js/Pages/Settings/Installments/Index.vue 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; +}