From 971a9e89d1451a942611a396b5704bf7e1eb1516 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 2 Oct 2025 18:35:02 +0200 Subject: [PATCH] add payment option --- .../Controllers/AccountBookingController.php | 53 +++++++ .../Controllers/AccountPaymentController.php | 150 ++++++++++++++++++ .../Controllers/PaymentSettingController.php | 67 ++++++++ app/Http/Requests/StoreBookingRequest.php | 24 +++ app/Http/Requests/StorePaymentRequest.php | 24 +++ .../Requests/UpdatePaymentSettingRequest.php | 24 +++ app/Models/Account.php | 14 ++ app/Models/AccountType.php | 5 + app/Models/Booking.php | 81 ++++++++++ app/Models/Payment.php | 60 +++++++ app/Models/PaymentSetting.php | 19 +++ ...025_10_02_120000_create_payments_table.php | 34 ++++ ...025_10_02_120100_create_bookings_table.php | 32 ++++ ..._recreate_payments_and_bookings_tables.php | 61 +++++++ ...2_140000_create_payment_settings_table.php | 26 +++ ...0000_add_activity_id_to_payments_table.php | 27 ++++ database/seeders/AccountTypeSeeder.php | 41 +---- database/seeders/DatabaseSeeder.php | 2 + database/seeders/PaymentSettingSeeder.php | 21 +++ .../js/Pages/Accounts/Bookings/Index.vue | 122 ++++++++++++++ .../js/Pages/Accounts/Payments/Index.vue | 115 ++++++++++++++ .../js/Pages/Cases/Partials/ContractTable.vue | 148 ++++++++++++++++- .../js/Pages/Cases/Partials/PaymentDialog.vue | 78 +++++++++ resources/js/Pages/Settings/Index.vue | 5 + .../js/Pages/Settings/Payments/Index.vue | 103 ++++++++++++ routes/breadcrumbs.php | 6 + routes/web.php | 19 +++ 27 files changed, 1327 insertions(+), 34 deletions(-) create mode 100644 app/Http/Controllers/AccountBookingController.php create mode 100644 app/Http/Controllers/AccountPaymentController.php create mode 100644 app/Http/Controllers/PaymentSettingController.php create mode 100644 app/Http/Requests/StoreBookingRequest.php create mode 100644 app/Http/Requests/StorePaymentRequest.php create mode 100644 app/Http/Requests/UpdatePaymentSettingRequest.php create mode 100644 app/Models/Booking.php create mode 100644 app/Models/PaymentSetting.php create mode 100644 database/migrations/2025_10_02_120000_create_payments_table.php create mode 100644 database/migrations/2025_10_02_120100_create_bookings_table.php create mode 100644 database/migrations/2025_10_02_130000_recreate_payments_and_bookings_tables.php create mode 100644 database/migrations/2025_10_02_140000_create_payment_settings_table.php create mode 100644 database/migrations/2025_10_02_150000_add_activity_id_to_payments_table.php create mode 100644 database/seeders/PaymentSettingSeeder.php create mode 100644 resources/js/Pages/Accounts/Bookings/Index.vue create mode 100644 resources/js/Pages/Accounts/Payments/Index.vue create mode 100644 resources/js/Pages/Cases/Partials/PaymentDialog.vue create mode 100644 resources/js/Pages/Settings/Payments/Index.vue diff --git a/app/Http/Controllers/AccountBookingController.php b/app/Http/Controllers/AccountBookingController.php new file mode 100644 index 0000000..0c7efb1 --- /dev/null +++ b/app/Http/Controllers/AccountBookingController.php @@ -0,0 +1,53 @@ +where('account_id', $account->id) + ->orderByDesc('booked_at') + ->get(['id', 'payment_id', 'amount_cents', 'type', 'description', 'booked_at', 'created_at']); + + return Inertia::render('Accounts/Bookings/Index', [ + 'account' => $account->only(['id', 'reference', 'description']), + 'bookings' => $bookings, + ]); + } + + public function store(StoreBookingRequest $request, Account $account): RedirectResponse + { + $validated = $request->validated(); + + Booking::query()->create([ + 'account_id' => $account->id, + 'payment_id' => $validated['payment_id'] ?? null, + 'amount_cents' => (int) round(((float) $validated['amount']) * 100), + 'type' => $validated['type'], + 'description' => $validated['description'] ?? null, + 'booked_at' => $validated['booked_at'] ?? now(), + ]); + + return back()->with('success', 'Booking created.'); + } + + public function destroy(Account $account, Booking $booking): RedirectResponse + { + if ($booking->account_id !== $account->id) { + abort(404); + } + + $booking->delete(); + + return back()->with('success', 'Booking deleted.'); + } +} diff --git a/app/Http/Controllers/AccountPaymentController.php b/app/Http/Controllers/AccountPaymentController.php new file mode 100644 index 0000000..6332aa6 --- /dev/null +++ b/app/Http/Controllers/AccountPaymentController.php @@ -0,0 +1,150 @@ +where('account_id', $account->id) + ->orderByDesc('paid_at') + ->get(['id', 'amount_cents', 'currency', 'reference', 'paid_at', 'created_at']) + ->map(function (Payment $p) { + return [ + 'id' => $p->id, + 'amount' => $p->amount, // accessor divides cents + 'currency' => $p->currency, + 'reference' => $p->reference, + 'paid_at' => $p->paid_at, + 'created_at' => $p->created_at, + ]; + }); + + return Inertia::render('Accounts/Payments/Index', [ + 'account' => $account->only(['id', 'reference', 'description']), + 'payments' => $payments, + ]); + } + + public function list(Account $account): JsonResponse + { + $payments = Payment::query() + ->where('account_id', $account->id) + ->orderByDesc('paid_at') + ->get(['id', 'amount_cents', 'currency', 'reference', 'paid_at', 'created_at']) + ->map(function (Payment $p) { + return [ + 'id' => $p->id, + 'amount' => $p->amount, + 'currency' => $p->currency, + 'reference' => $p->reference, + 'paid_at' => optional($p->paid_at)?->toDateString(), + 'created_at' => optional($p->created_at)?->toDateTimeString(), + ]; + }); + + return response()->json([ + 'account' => [ + 'id' => $account->id, + 'balance_amount' => $account->balance_amount, + ], + 'payments' => $payments, + ]); + } + + public function store(StorePaymentRequest $request, Account $account): RedirectResponse + { + $validated = $request->validated(); + + $amountCents = (int) round(((float) $validated['amount']) * 100); + + // Load defaults from settings + $settings = PaymentSetting::query()->first(); + $defaultCurrency = strtoupper($settings->default_currency ?? 'EUR'); + + $payment = Payment::query()->create([ + 'account_id' => $account->id, + 'amount_cents' => $amountCents, + 'currency' => strtoupper($validated['currency'] ?? $defaultCurrency), + 'reference' => $validated['reference'] ?? null, + 'paid_at' => $validated['paid_at'] ?? now(), + 'meta' => $validated['meta'] ?? null, + 'created_by' => $request->user()?->id, + ]); + + // Auto-create a credit booking for this payment to reduce account balance + Booking::query()->create([ + 'account_id' => $account->id, + 'payment_id' => $payment->id, + 'amount_cents' => $amountCents, + 'type' => 'credit', + 'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo', + 'booked_at' => $payment->paid_at ?? now(), + ]); + + // Optionally create an activity entry with default decision/action + if ($settings && ($settings->create_activity_on_payment ?? false)) { + $note = $settings->activity_note_template ?? 'Prejeto plačilo'; + $note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $payment->currency], $note); + $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, + ]); + // Link the payment to the activity + $payment->update(['activity_id' => $activity->id]); + } + } + + return back()->with('success', 'Payment created.'); + } + + public function destroy(Account $account, Payment $payment): RedirectResponse|JsonResponse + { + if ($payment->account_id !== $account->id) { + abort(404); + } + + // Delete related booking(s) to revert balance via model events + Booking::query()->where('payment_id', $payment->id)->get()->each->delete(); + + // Optionally delete related activity + if ($payment->activity_id ?? null) { + $activity = Activity::query()->find($payment->activity_id); + if ($activity) { + $activity->delete(); + } + } + + $payment->delete(); + + if (request()->wantsJson()) { + $account->refresh(); + return response()->json([ + 'ok' => true, + 'balance_amount' => $account->balance_amount, + ]); + } + + return back()->with('success', 'Payment deleted.'); + } +} diff --git a/app/Http/Controllers/PaymentSettingController.php b/app/Http/Controllers/PaymentSettingController.php new file mode 100644 index 0000000..314096e --- /dev/null +++ b/app/Http/Controllers/PaymentSettingController.php @@ -0,0 +1,67 @@ +first(); + if (! $setting) { + $setting = PaymentSetting::query()->create([ + 'default_currency' => 'EUR', + 'create_activity_on_payment' => false, + 'default_decision_id' => null, + 'default_action_id' => null, + 'activity_note_template' => 'Prejeto plačilo: {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/Payments/Index', [ + 'setting' => [ + 'id' => $setting->id, + 'default_currency' => $setting->default_currency, + 'create_activity_on_payment' => (bool) $setting->create_activity_on_payment, + '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(UpdatePaymentSettingRequest $request): RedirectResponse + { + $data = $request->validated(); + $setting = PaymentSetting::query()->firstOrFail(); + + // Ensure boolean cast for checkbox + $data['create_activity_on_payment'] = (bool) ($data['create_activity_on_payment'] ?? false); + + $setting->fill($data)->save(); + + return back()->with('success', 'Payment settings updated.'); + } +} diff --git a/app/Http/Requests/StoreBookingRequest.php b/app/Http/Requests/StoreBookingRequest.php new file mode 100644 index 0000000..184346a --- /dev/null +++ b/app/Http/Requests/StoreBookingRequest.php @@ -0,0 +1,24 @@ + ['required', 'numeric', 'min:0.01'], + 'type' => ['required', 'in:debit,credit'], + 'description' => ['nullable', 'string', 'max:255'], + 'booked_at' => ['nullable', 'date'], + 'payment_id' => ['nullable', 'integer', 'exists:payments,id'], + ]; + } +} diff --git a/app/Http/Requests/StorePaymentRequest.php b/app/Http/Requests/StorePaymentRequest.php new file mode 100644 index 0000000..fc4382c --- /dev/null +++ b/app/Http/Requests/StorePaymentRequest.php @@ -0,0 +1,24 @@ + ['required', 'numeric', 'min:0.01'], + 'currency' => ['nullable', 'string', 'size:3'], + 'reference' => ['nullable', 'string', 'max:100'], + 'paid_at' => ['nullable', 'date'], + 'meta' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/UpdatePaymentSettingRequest.php b/app/Http/Requests/UpdatePaymentSettingRequest.php new file mode 100644 index 0000000..1156c07 --- /dev/null +++ b/app/Http/Requests/UpdatePaymentSettingRequest.php @@ -0,0 +1,24 @@ + ['required', 'string', 'size:3'], + 'create_activity_on_payment' => ['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 8f5bb2e..8ebfc9b 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -42,4 +42,18 @@ public function debts(): HasMany return $this->hasMany(\App\Models\Debt::class); } + public function payments(): HasMany + { + return $this->hasMany(\App\Models\Payment::class); + } + + public function bookings(): HasMany + { + return $this->hasMany(\App\Models\Booking::class); + } + + public function contract(): BelongsTo + { + return $this->belongsTo(\App\Models\Contract::class); + } } diff --git a/app/Models/AccountType.php b/app/Models/AccountType.php index 2f9518e..28de31d 100644 --- a/app/Models/AccountType.php +++ b/app/Models/AccountType.php @@ -8,4 +8,9 @@ class AccountType extends Model { use HasFactory; + + protected $fillable = [ + 'name', + 'description', + ]; } diff --git a/app/Models/Booking.php b/app/Models/Booking.php new file mode 100644 index 0000000..f7ce0d5 --- /dev/null +++ b/app/Models/Booking.php @@ -0,0 +1,81 @@ + 'datetime', + 'amount_cents' => 'integer', + ]; + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } + + protected static function booted(): void + { + static::created(function (Booking $booking): void { + $booking->applyToAccountBalance(+1); + }); + + static::deleted(function (Booking $booking): void { + // Soft delete should revert the effect on balance + $booking->applyToAccountBalance(-1); + }); + + static::restored(function (Booking $booking): void { + // Re-apply when restored + $booking->applyToAccountBalance(+1); + }); + } + + /** + * Apply or revert the booking effect on account balance. + * + * @param int $multiplier +1 to apply, -1 to revert + */ + protected function applyToAccountBalance(int $multiplier = 1): void + { + $account = $this->account; + if (! $account) { + return; + } + + $delta = ($this->amount_cents / 100.0); + if ($this->type === 'credit') { + // Credit decreases the receivable (balance goes down) + $delta = -$delta; + } + // Debit increases receivable (balance up), credit decreases + $account->forceFill([ + 'balance_amount' => (float) ($account->balance_amount ?? 0) + ($multiplier * $delta), + ])->save(); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php index e8a95df..b3fe440 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -2,16 +2,76 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; +use App\Models\Activity; class Payment extends Model { use HasFactory; + use SoftDeletes; + + protected $fillable = [ + 'account_id', + 'amount_cents', + 'currency', + 'reference', + 'paid_at', + 'meta', + 'created_by', + 'activity_id', + ]; + + protected function casts(): array + { + return [ + 'paid_at' => 'datetime', + 'meta' => 'array', + 'amount_cents' => 'integer', + ]; + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + public function bookings(): HasMany + { + return $this->hasMany(Booking::class); + } + + public function activity(): BelongsTo + { + return $this->belongsTo(Activity::class); + } public function type(): BelongsTo { return $this->belongsTo(\App\Models\PaymentType::class); } + + /** + * Accessor to expose decimal amount for JSON serialization and UI convenience. + */ + protected function amount(): Attribute + { + return Attribute::get(function () { + $cents = (int) ($this->attributes['amount_cents'] ?? 0); + + return $cents / 100; + }); + } + + /** + * Mutator to set amount via decimal; stores in cents. + */ + public function setAmountAttribute($value): void + { + $this->attributes['amount_cents'] = (int) round(((float) $value) * 100); + } } diff --git a/app/Models/PaymentSetting.php b/app/Models/PaymentSetting.php new file mode 100644 index 0000000..5f46606 --- /dev/null +++ b/app/Models/PaymentSetting.php @@ -0,0 +1,19 @@ +id(); + $table->foreignId('account_id')->constrained()->cascadeOnDelete(); + $table->bigInteger('amount_cents'); + $table->string('currency', 3)->default('EUR'); + $table->string('reference')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->jsonb('meta')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['account_id', 'paid_at']); + $table->index('reference'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2025_10_02_120100_create_bookings_table.php b/database/migrations/2025_10_02_120100_create_bookings_table.php new file mode 100644 index 0000000..b1fbd89 --- /dev/null +++ b/database/migrations/2025_10_02_120100_create_bookings_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('account_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->nullable()->constrained('payments')->nullOnDelete(); + $table->bigInteger('amount_cents'); + $table->enum('type', ['debit', 'credit']); + $table->string('description')->nullable(); + $table->timestamp('booked_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['account_id', 'booked_at']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('bookings'); + } +}; diff --git a/database/migrations/2025_10_02_130000_recreate_payments_and_bookings_tables.php b/database/migrations/2025_10_02_130000_recreate_payments_and_bookings_tables.php new file mode 100644 index 0000000..3c6484e --- /dev/null +++ b/database/migrations/2025_10_02_130000_recreate_payments_and_bookings_tables.php @@ -0,0 +1,61 @@ +id(); + $table->foreignId('account_id')->constrained()->cascadeOnDelete(); + $table->bigInteger('amount_cents'); + $table->string('currency', 3)->default('EUR'); + $table->string('reference')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->jsonb('meta')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['account_id', 'paid_at']); + $table->index('reference'); + }); + + // Recreate bookings + Schema::create('bookings', function (Blueprint $table): void { + $table->id(); + $table->foreignId('account_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->nullable()->constrained('payments')->nullOnDelete(); + $table->bigInteger('amount_cents'); + $table->enum('type', ['debit', 'credit']); + $table->string('description')->nullable(); + $table->timestamp('booked_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['account_id', 'booked_at']); + }); + } + + public function down(): void + { + if (Schema::hasTable('bookings')) { + Schema::drop('bookings'); + } + if (Schema::hasTable('payments')) { + Schema::drop('payments'); + } + } +}; diff --git a/database/migrations/2025_10_02_140000_create_payment_settings_table.php b/database/migrations/2025_10_02_140000_create_payment_settings_table.php new file mode 100644 index 0000000..aa55a81 --- /dev/null +++ b/database/migrations/2025_10_02_140000_create_payment_settings_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('default_currency', 3)->default('EUR'); + $table->boolean('create_activity_on_payment')->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('payment_settings'); + } +}; diff --git a/database/migrations/2025_10_02_150000_add_activity_id_to_payments_table.php b/database/migrations/2025_10_02_150000_add_activity_id_to_payments_table.php new file mode 100644 index 0000000..c9ae7e2 --- /dev/null +++ b/database/migrations/2025_10_02_150000_add_activity_id_to_payments_table.php @@ -0,0 +1,27 @@ +foreignId('activity_id')->nullable()->constrained('activities')->nullOnDelete()->after('created_by'); + $table->index('activity_id'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('payments') && Schema::hasColumn('payments', 'activity_id')) { + Schema::table('payments', function (Blueprint $table): void { + $table->dropConstrainedForeignId('activity_id'); + }); + } + } +}; diff --git a/database/seeders/AccountTypeSeeder.php b/database/seeders/AccountTypeSeeder.php index c44d1b3..f47762d 100644 --- a/database/seeders/AccountTypeSeeder.php +++ b/database/seeders/AccountTypeSeeder.php @@ -4,7 +4,6 @@ use App\Models\AccountType; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\DB; class AccountTypeSeeder extends Seeder { @@ -12,40 +11,16 @@ public function run(): void { $now = now(); - // If table is empty, insert with explicit IDs so id=1 exists (matches default logic elsewhere) - if (AccountType::count() === 0) { - $rows = [ - ['id' => 1, 'name' => 'Default', 'description' => 'Default account type', 'created_at' => $now, 'updated_at' => $now], - ['id' => 2, 'name' => 'Primary', 'description' => 'Primary account', 'created_at' => $now, 'updated_at' => $now], - ['id' => 3, 'name' => 'Secondary', 'description' => 'Secondary account', 'created_at' => $now, 'updated_at' => $now], - ['id' => 4, 'name' => 'Savings', 'description' => 'Savings account', 'created_at' => $now, 'updated_at' => $now], - ['id' => 5, 'name' => 'Checking', 'description' => 'Checking account', 'created_at' => $now, 'updated_at' => $now], - ['id' => 6, 'name' => 'Credit', 'description' => 'Credit account', 'created_at' => $now, 'updated_at' => $now], - ['id' => 7, 'name' => 'Loan', 'description' => 'Loan account', 'created_at' => $now, 'updated_at' => $now], - ['id' => 8, 'name' => 'Other', 'description' => 'Other account type', 'created_at' => $now, 'updated_at' => $now], - ]; - DB::table('account_types')->insert($rows); - - return; - } - - // If table already has data, ensure the basics exist (idempotent, no explicit IDs) - $names = [ - 'Default' => 'Default account type', - 'Primary' => 'Primary account', - 'Secondary' => 'Secondary account', - 'Savings' => 'Savings account', - 'Checking' => 'Checking account', - 'Credit' => 'Credit account', - 'Loan' => 'Loan account', - 'Other' => 'Other account type', + $rows = [ + ['name' => 'Receivables', 'description' => 'Standard receivable account'], + ['name' => 'Payables', 'description' => 'Standard payable account'], + ['name' => 'Loan', 'description' => 'Loan and credit account'], + ['name' => 'Savings', 'description' => 'Savings account type'], + ['name' => 'Current', 'description' => 'Current/operational account'], ]; - foreach ($names as $name => $desc) { - AccountType::updateOrCreate( - ['name' => $name], - ['description' => $desc] - ); + foreach ($rows as $row) { + AccountType::updateOrCreate(['name' => $row['name']], ['description' => $row['description']]); } } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 309627d..52b4848 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -29,6 +29,8 @@ public function run(): void ); $this->call([ + AccountTypeSeeder::class, + PaymentSettingSeeder::class, PersonSeeder::class, SegmentSeeder::class, ActionSeeder::class, diff --git a/database/seeders/PaymentSettingSeeder.php b/database/seeders/PaymentSettingSeeder.php new file mode 100644 index 0000000..1edbd81 --- /dev/null +++ b/database/seeders/PaymentSettingSeeder.php @@ -0,0 +1,21 @@ +firstOrCreate([], [ + 'default_currency' => 'EUR', + 'create_activity_on_payment' => false, + 'default_decision_id' => null, + 'default_action_id' => null, + 'activity_note_template' => 'Prejeto plačilo: {amount} {currency}', + ]); + } +} diff --git a/resources/js/Pages/Accounts/Bookings/Index.vue b/resources/js/Pages/Accounts/Bookings/Index.vue new file mode 100644 index 0000000..e4bd38b --- /dev/null +++ b/resources/js/Pages/Accounts/Bookings/Index.vue @@ -0,0 +1,122 @@ + + + diff --git a/resources/js/Pages/Accounts/Payments/Index.vue b/resources/js/Pages/Accounts/Payments/Index.vue new file mode 100644 index 0000000..8954f1f --- /dev/null +++ b/resources/js/Pages/Accounts/Payments/Index.vue @@ -0,0 +1,115 @@ + + + diff --git a/resources/js/Pages/Cases/Partials/ContractTable.vue b/resources/js/Pages/Cases/Partials/ContractTable.vue index 5ba4265..c953025 100644 --- a/resources/js/Pages/Cases/Partials/ContractTable.vue +++ b/resources/js/Pages/Cases/Partials/ContractTable.vue @@ -10,6 +10,7 @@ import { import Dropdown from "@/Components/Dropdown.vue"; import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue"; import CaseObjectsDialog from "./CaseObjectsDialog.vue"; +import PaymentDialog from "./PaymentDialog.vue"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { faCircleInfo, @@ -48,7 +49,8 @@ const onAddActivity = (c) => emit("add-activity", c); // CaseObject dialog state import { ref, computed } from "vue"; -import { router } from "@inertiajs/vue3"; +import { router, useForm } from "@inertiajs/vue3"; +import axios from "axios"; const showObjectDialog = ref(false); const showObjectsList = ref(false); const selectedContract = ref(null); @@ -144,6 +146,81 @@ const doChangeSegment = () => { ); } }; + +// Add Payment modal state +const showPaymentDialog = ref(false); +const paymentContract = ref(null); +const paymentForm = useForm({ + amount: null, + currency: "EUR", + paid_at: null, + reference: "", +}); +const openPaymentDialog = (c) => { + paymentContract.value = c; + paymentForm.reset(); + paymentForm.paid_at = todayStr.value; + showPaymentDialog.value = true; +}; +const closePaymentDialog = () => { + showPaymentDialog.value = false; + paymentContract.value = null; +}; +const submitPayment = () => { + if (!paymentContract.value?.account?.id) { + return; + } + const accountId = paymentContract.value.account.id; + paymentForm.post(route("accounts.payments.store", { account: accountId }), { + preserveScroll: true, + onSuccess: () => { + closePaymentDialog(); + // Reload contracts and activities (new payment may create an activity) + router.reload({ only: ["contracts", "activities"] }); + }, + }); +}; + +// View Payments dialog state and logic +const showPaymentsDialog = ref(false); +const paymentsForContract = ref([]); +const paymentsLoading = ref(false); +const openPaymentsDialog = async (c) => { + selectedContract.value = c; + showPaymentsDialog.value = true; + await loadPayments(); +}; +const closePaymentsDialog = () => { + showPaymentsDialog.value = false; + selectedContract.value = null; + paymentsForContract.value = []; +}; +const loadPayments = async () => { + if (!selectedContract.value?.account?.id) return; + paymentsLoading.value = true; + try { + const { data } = await axios.get(route("accounts.payments.list", { account: selectedContract.value.account.id })); + paymentsForContract.value = data.payments || []; + } finally { + paymentsLoading.value = false; + } +}; +const deletePayment = (paymentId) => { + if (!selectedContract.value?.account?.id) return; + const accountId = selectedContract.value.account.id; + router.delete(route("accounts.payments.destroy", { account: accountId, payment: paymentId }), { + preserveScroll: true, + preserveState: true, + only: ["contracts", "activities"], + onSuccess: async () => { + await loadPayments(); + }, + onError: async () => { + // Even if there is an error, try to refresh payments list + await loadPayments(); + }, + }); +}; @@ -460,4 +555,55 @@ const doChangeSegment = () => { :client_case="client_case" :contract="selectedContract" /> + + + + +
+
+
+
+ Plačila za pogodbo + {{ selectedContract?.reference }} +
+ +
+
+
Nalaganje…
+ +
+
+ + +
+
+
diff --git a/resources/js/Pages/Cases/Partials/PaymentDialog.vue b/resources/js/Pages/Cases/Partials/PaymentDialog.vue new file mode 100644 index 0000000..964206d --- /dev/null +++ b/resources/js/Pages/Cases/Partials/PaymentDialog.vue @@ -0,0 +1,78 @@ + + + diff --git a/resources/js/Pages/Settings/Index.vue b/resources/js/Pages/Settings/Index.vue index 285c9fa..45d1903 100644 --- a/resources/js/Pages/Settings/Index.vue +++ b/resources/js/Pages/Settings/Index.vue @@ -14,6 +14,11 @@ import { Link } from '@inertiajs/vue3';

Manage segments used across the app.

Open Segments +
+

Payments

+

Defaults for payments and auto-activity.

+ Open Payment Settings +

Workflow

Configure actions and decisions relationships.

diff --git a/resources/js/Pages/Settings/Payments/Index.vue b/resources/js/Pages/Settings/Payments/Index.vue new file mode 100644 index 0000000..d10e3d2 --- /dev/null +++ b/resources/js/Pages/Settings/Payments/Index.vue @@ -0,0 +1,103 @@ + + + diff --git a/routes/breadcrumbs.php b/routes/breadcrumbs.php index a574035..7c96e65 100644 --- a/routes/breadcrumbs.php +++ b/routes/breadcrumbs.php @@ -68,4 +68,10 @@ Breadcrumbs::for('settings.fieldjob.index', function (BreadcrumbTrail $trail) { $trail->parent('settings'); $trail->push('Terensko delo', route('settings.fieldjob.index')); +}); + +// Dashboard > Settings > Payments +Breadcrumbs::for('settings.payment.edit', function (BreadcrumbTrail $trail) { + $trail->parent('settings'); + $trail->push('Plačila', route('settings.payment.edit')); }); \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 9d91601..88725a8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,8 @@ name('accounts.')->group(function (): void { + Route::get('payments', [AccountPaymentController::class, 'index'])->name('payments.index'); + Route::get('payments/list', [AccountPaymentController::class, 'list'])->name('payments.list'); + Route::post('payments', [AccountPaymentController::class, 'store'])->name('payments.store'); + Route::delete('payments/{payment}', [AccountPaymentController::class, 'destroy'])->name('payments.destroy'); + + 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'); + }); + + // 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'); + Route::get('types/address', function (Request $request) { $types = App\Models\Person\AddressType::all();