add payment option

This commit is contained in:
Simon 2025-10-02 18:35:02 +02:00
parent 0e0912c81b
commit 971a9e89d1
27 changed files with 1327 additions and 34 deletions

View File

@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreBookingRequest;
use App\Models\Account;
use App\Models\Booking;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class AccountBookingController extends Controller
{
public function index(Account $account): Response
{
$bookings = Booking::query()
->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.');
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePaymentRequest;
use App\Models\Account;
use App\Models\Activity;
use App\Models\Booking;
use App\Models\Payment;
use App\Models\PaymentSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Inertia\Inertia;
use Inertia\Response;
class AccountPaymentController extends Controller
{
public function index(Account $account): Response
{
$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, // 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.');
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdatePaymentSettingRequest;
use App\Models\Action;
use App\Models\Decision;
use App\Models\PaymentSetting;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class PaymentSettingController extends Controller
{
public function edit(): Response
{
$setting = PaymentSetting::query()->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.');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreBookingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['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'],
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePaymentRequest 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'],
'paid_at' => ['nullable', 'date'],
'meta' => ['nullable', 'array'],
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePaymentSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'default_currency' => ['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'],
];
}
}

View File

@ -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);
}
}

View File

@ -8,4 +8,9 @@
class AccountType extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
];
}

81
app/Models/Booking.php Normal file
View File

@ -0,0 +1,81 @@
<?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 Booking extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = [
'account_id',
'payment_id',
'amount_cents',
'type',
'description',
'booked_at',
];
protected function casts(): array
{
return [
'booked_at' => '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();
}
}

View File

@ -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);
}
}

View File

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

View File

@ -0,0 +1,34 @@
<?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
{
if (! Schema::hasTable('payments')) {
Schema::create('payments', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,32 @@
<?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
{
if (! Schema::hasTable('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
{
Schema::dropIfExists('bookings');
}
};

View File

@ -0,0 +1,61 @@
<?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
{
// Drop in reverse dependency order
if (Schema::hasTable('bookings')) {
Schema::drop('bookings');
}
if (Schema::hasTable('payments')) {
Schema::drop('payments');
}
// Recreate payments
Schema::create('payments', function (Blueprint $table): void {
$table->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');
}
}
};

View File

@ -0,0 +1,26 @@
<?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('payment_settings', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,27 @@
<?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
{
if (Schema::hasTable('payments') && ! Schema::hasColumn('payments', 'activity_id')) {
Schema::table('payments', function (Blueprint $table): void {
$table->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');
});
}
}
};

View File

@ -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']]);
}
}
}

View File

@ -29,6 +29,8 @@ public function run(): void
);
$this->call([
AccountTypeSeeder::class,
PaymentSettingSeeder::class,
PersonSeeder::class,
SegmentSeeder::class,
ActionSeeder::class,

View File

@ -0,0 +1,21 @@
<?php
namespace Database\Seeders;
use App\Models\PaymentSetting;
use Illuminate\Database\Seeder;
class PaymentSettingSeeder extends Seeder
{
public function run(): void
{
// Create default record if not exists
PaymentSetting::query()->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}',
]);
}
}

View File

@ -0,0 +1,122 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import { useForm, Link } from '@inertiajs/vue3'
import { ref } from 'vue'
const props = defineProps({
account: Object,
bookings: Array,
})
const showCreate = ref(false)
const form = useForm({
amount: '',
type: 'credit',
description: '',
booked_at: '',
payment_id: null,
})
function openCreate() {
form.reset()
form.clearErrors()
showCreate.value = true
}
function closeCreate() {
showCreate.value = false
form.clearErrors()
}
function submit() {
form.post(route('accounts.bookings.store', { account: props.account.id }), {
preserveScroll: true,
onSuccess: () => closeCreate(),
})
}
</script>
<template>
<AppLayout :title="`Bookings - ${account.reference || account.id}`">
<template #header>
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Bookings · {{ account.reference || account.id }}</h2>
<div class="flex items-center gap-2">
<Link :href="route('accounts.payments.index', { account: account.id })" class="text-sm underline">Payments</Link>
<button class="px-3 py-2 bg-emerald-600 text-white rounded" @click="openCreate">New Booking</button>
</div>
</div>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6">
<table class="min-w-full text-left text-sm">
<thead class="border-b text-gray-500">
<tr>
<th class="py-2 pr-4">Booked at</th>
<th class="py-2 pr-4">Type</th>
<th class="py-2 pr-4">Amount</th>
<th class="py-2 pr-4">Description</th>
<th class="py-2 pr-0 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="b in bookings" :key="b.id" class="border-b last:border-0">
<td class="py-2 pr-4">{{ b.booked_at ? new Date(b.booked_at).toLocaleString() : '-' }}</td>
<td class="py-2 pr-4">
<span :class="['px-2 py-0.5 rounded text-xs', b.type === 'credit' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-800']">
{{ b.type }}
</span>
</td>
<td class="py-2 pr-4 font-medium">{{ (b.amount_cents / 100).toFixed(2) }}</td>
<td class="py-2 pr-4">{{ b.description || '-' }}</td>
<td class="py-2 pr-0 text-right">
<form :action="route('accounts.bookings.destroy', { account: account.id, booking: b.id })" method="post" @submit.prevent="$inertia.delete(route('accounts.bookings.destroy', { account: account.id, booking: b.id }), { preserveScroll: true })">
<button type="submit" class="text-red-600 hover:underline">Delete</button>
</form>
</td>
</tr>
<tr v-if="!bookings?.length">
<td colspan="5" class="py-6 text-center text-gray-500">No bookings.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="showCreate" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="closeCreate"></div>
<div class="relative bg-white rounded shadow-lg w-[32rem] max-w-[90%] p-5">
<div class="text-lg font-semibold mb-2">New Booking</div>
<div class="grid grid-cols-1 gap-3">
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-sm text-gray-700 mb-1">Type</label>
<select v-model="form.type" class="w-full border rounded px-3 py-2">
<option value="credit">Credit</option>
<option value="debit">Debit</option>
</select>
</div>
<div class="flex-1">
<label class="block text-sm text-gray-700 mb-1">Amount</label>
<input type="number" step="0.01" v-model="form.amount" class="w-full border rounded px-3 py-2" :class="form.errors.amount && 'border-red-500'" />
<div v-if="form.errors.amount" class="text-xs text-red-600 mt-1">{{ form.errors.amount }}</div>
</div>
</div>
<div>
<label class="block text-sm text-gray-700 mb-1">Booked at</label>
<input type="datetime-local" v-model="form.booked_at" class="w-full border rounded px-3 py-2" />
</div>
<div>
<label class="block text-sm text-gray-700 mb-1">Description</label>
<input type="text" v-model="form.description" class="w-full border rounded px-3 py-2" />
</div>
</div>
<div class="flex items-center justify-end gap-2 mt-5">
<button class="px-3 py-1.5 border rounded" @click="closeCreate" :disabled="form.processing">Cancel</button>
<button class="px-3 py-1.5 rounded text-white bg-emerald-600 disabled:opacity-60" @click="submit" :disabled="form.processing">Create</button>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@ -0,0 +1,115 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import { useForm, Link } from '@inertiajs/vue3'
import { ref } from 'vue'
const props = defineProps({
account: Object,
payments: Array,
})
const showCreate = ref(false)
const form = useForm({
amount: '',
currency: 'EUR',
reference: '',
paid_at: '',
})
function openCreate() {
form.reset()
form.clearErrors()
showCreate.value = true
}
function closeCreate() {
showCreate.value = false
form.clearErrors()
}
function submit() {
form.post(route('accounts.payments.store', { account: props.account.id }), {
preserveScroll: true,
onSuccess: () => closeCreate(),
})
}
</script>
<template>
<AppLayout :title="`Payments - ${account.reference || account.id}`">
<template #header>
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Payments · {{ account.reference || account.id }}</h2>
<div class="flex items-center gap-2">
<Link :href="route('accounts.bookings.index', { account: account.id })" class="text-sm underline">Bookings</Link>
<button class="px-3 py-2 bg-emerald-600 text-white rounded" @click="openCreate">New Payment</button>
</div>
</div>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6">
<table class="min-w-full text-left text-sm">
<thead class="border-b text-gray-500">
<tr>
<th class="py-2 pr-4">Paid at</th>
<th class="py-2 pr-4">Reference</th>
<th class="py-2 pr-4">Amount</th>
<th class="py-2 pr-0 text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="p in payments" :key="p.id" class="border-b last:border-0">
<td class="py-2 pr-4">{{ p.paid_at ? new Date(p.paid_at).toLocaleString() : '-' }}</td>
<td class="py-2 pr-4">{{ p.reference || '-' }}</td>
<td class="py-2 pr-4 font-medium">{{ Number(p.amount).toFixed(2) }} {{ p.currency }}</td>
<td class="py-2 pr-0 text-right">
<form :action="route('accounts.payments.destroy', { account: account.id, payment: p.id })" method="post" @submit.prevent="$inertia.delete(route('accounts.payments.destroy', { account: account.id, payment: p.id }), { preserveScroll: true })">
<button type="submit" class="text-red-600 hover:underline">Delete</button>
</form>
</td>
</tr>
<tr v-if="!payments?.length">
<td colspan="4" class="py-6 text-center text-gray-500">No payments.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="showCreate" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="closeCreate"></div>
<div class="relative bg-white rounded shadow-lg w-[32rem] max-w-[90%] p-5">
<div class="text-lg font-semibold mb-2">New Payment</div>
<div class="grid grid-cols-1 gap-3">
<div>
<label class="block text-sm text-gray-700 mb-1">Amount</label>
<input type="number" step="0.01" v-model="form.amount" class="w-full border rounded px-3 py-2" :class="form.errors.amount && 'border-red-500'" />
<div v-if="form.errors.amount" class="text-xs text-red-600 mt-1">{{ form.errors.amount }}</div>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-sm text-gray-700 mb-1">Currency</label>
<input type="text" maxlength="3" v-model="form.currency" class="w-full border rounded px-3 py-2 uppercase" />
</div>
<div class="flex-1">
<label class="block text-sm text-gray-700 mb-1">Paid at</label>
<input type="datetime-local" v-model="form.paid_at" class="w-full border rounded px-3 py-2" />
</div>
</div>
<div>
<label class="block text-sm text-gray-700 mb-1">Reference</label>
<input type="text" v-model="form.reference" class="w-full border rounded px-3 py-2" :class="form.errors.reference && 'border-red-500'" />
<div v-if="form.errors.reference" class="text-xs text-red-600 mt-1">{{ form.errors.reference }}</div>
</div>
</div>
<div class="flex items-center justify-end gap-2 mt-5">
<button class="px-3 py-1.5 border rounded" @click="closeCreate" :disabled="form.processing">Cancel</button>
<button class="px-3 py-1.5 rounded text-white bg-emerald-600 disabled:opacity-60" @click="submit" :disabled="form.processing">Create</button>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@ -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();
},
});
};
</script>
<template>
@ -399,6 +476,15 @@ const doChangeSegment = () => {
<span>Briši</span>
</button>
<div class="my-1 border-t border-gray-100" />
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="openPaymentsDialog(c)"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
<span>Plačila</span>
</button>
<div class="my-1 border-t border-gray-100" />
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@ -407,6 +493,15 @@ const doChangeSegment = () => {
<FontAwesomeIcon :icon="faListCheck" class="h-4 w-4 text-gray-600" />
<span>Aktivnost</span>
</button>
<div class="my-1 border-t border-gray-100" />
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="openPaymentDialog(c)"
>
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
<span>Dodaj plačilo</span>
</button>
</template>
</Dropdown>
</FwbTableCell>
@ -460,4 +555,55 @@ const doChangeSegment = () => {
:client_case="client_case"
:contract="selectedContract"
/>
<PaymentDialog :show="showPaymentDialog" :form="paymentForm" @close="closePaymentDialog" @submit="submitPayment" />
<!-- View Payments Dialog -->
<div v-if="showPaymentsDialog" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div class="bg-white rounded-lg shadow-lg p-4 w-full max-w-lg">
<div class="flex items-center justify-between">
<div class="text-base font-medium text-gray-800">
Plačila za pogodbo
<span class="text-gray-600">{{ selectedContract?.reference }}</span>
</div>
<button type="button" class="text-sm text-gray-500 hover:text-gray-700" @click="closePaymentsDialog">Zapri</button>
</div>
<div class="mt-3">
<div v-if="paymentsLoading" class="text-sm text-gray-500">Nalaganje</div>
<template v-else>
<div v-if="paymentsForContract.length === 0" class="text-sm text-gray-500">Ni plačil.</div>
<div v-else class="divide-y divide-gray-100 border rounded">
<div v-for="p in paymentsForContract" :key="p.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: p.currency || 'EUR' }).format(p.amount ?? 0)
}}
</div>
<div class="text-xs text-gray-500">
<span>{{ formatDate(p.paid_at) }}</span>
<span v-if="p.reference" class="ml-2">Sklic: {{ p.reference }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
@click="deletePayment(p.id)"
title="Izbriši plačilo"
>
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4 text-red-600" />
<span class="text-sm">Briši</span>
</button>
</div>
</div>
</div>
</template>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="loadPayments">Osveži</button>
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" @click="closePaymentsDialog">Zapri</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,78 @@
<script setup>
import DialogModal from "@/Components/DialogModal.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>
<DialogModal :show="show" @close="onClose">
<template #title>Dodaj plačilo</template>
<template #content>
<div class="space-y-3">
<div>
<label class="block text-sm text-gray-700 mb-1">Znesek</label>
<input
type="number"
step="0.01"
min="0"
v-model.number="form.amount"
class="w-full rounded border-gray-300"
/>
<div v-if="form.errors?.amount" class="text-sm text-red-600 mt-0.5">
{{ form.errors.amount }}
</div>
</div>
<div class="flex gap-2">
<div class="flex-1">
<label class="block text-sm text-gray-700 mb-1">Valuta</label>
<input
type="text"
v-model="form.currency"
class="w-full rounded border-gray-300"
maxlength="3"
/>
<div v-if="form.errors?.currency" class="text-sm text-red-600 mt-0.5">
{{ form.errors.currency }}
</div>
</div>
<div class="flex-1">
<label class="block text-sm text-gray-700 mb-1">Datum</label>
<input type="date" v-model="form.paid_at" class="w-full rounded border-gray-300" />
<div v-if="form.errors?.paid_at" class="text-sm text-red-600 mt-0.5">
{{ form.errors.paid_at }}
</div>
</div>
</div>
<div>
<label class="block text-sm text-gray-700 mb-1">Sklic</label>
<input type="text" v-model="form.reference" class="w-full rounded border-gray-300" />
<div v-if="form.errors?.reference" class="text-sm text-red-600 mt-0.5">
{{ form.errors.reference }}
</div>
</div>
</div>
</template>
<template #footer>
<button type="button" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="onClose">
Prekliči
</button>
<button
type="button"
class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
:disabled="form.processing"
@click="onSubmit"
>
Shrani
</button>
</template>
</DialogModal>
</template>

View File

@ -14,6 +14,11 @@ import { Link } from '@inertiajs/vue3';
<p class="text-sm text-gray-600 mb-4">Manage segments used across the app.</p>
<Link :href="route('settings.segments')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Open Segments</Link>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Payments</h3>
<p class="text-sm text-gray-600 mb-4">Defaults for payments and auto-activity.</p>
<Link :href="route('settings.payment.edit')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Open Payment Settings</Link>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Workflow</h3>
<p class="text-sm text-gray-600 mb-4">Configure actions and decisions relationships.</p>

View File

@ -0,0 +1,103 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import { useForm } from '@inertiajs/vue3'
import { computed, watch } from 'vue'
const props = defineProps({
setting: Object,
decisions: Array,
actions: Array,
})
const form = useForm({
default_currency: props.setting?.default_currency ?? 'EUR',
create_activity_on_payment: !!props.setting?.create_activity_on_payment,
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 ?? 'Prejeto plačilo: {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 {
// If current decision not in filtered list, clear it
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.payment.update'), {
preserveScroll: true,
})
}
</script>
<template>
<AppLayout title="Nastavitve plačil">
<template #header></template>
<div class="max-w-3xl mx-auto p-6">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h1 class="text-xl font-semibold text-gray-900">Nastavitve plačil</h1>
<div class="mt-6 grid gap-6">
<div>
<label class="block text-sm text-gray-700 mb-1">Privzeta valuta</label>
<input type="text" maxlength="3" v-model="form.default_currency" class="w-40 rounded border-gray-300" />
<div v-if="form.errors.default_currency" class="text-sm text-red-600 mt-1">{{ form.errors.default_currency }}</div>
</div>
<div>
<label class="inline-flex items-center gap-2">
<input type="checkbox" v-model="form.create_activity_on_payment" />
<span class="text-sm text-gray-700">Ustvari aktivnost ob dodanem plačilu</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm text-gray-700 mb-1">Privzeto dejanje</label>
<select v-model="form.default_action_id" class="w-full rounded border-gray-300">
<option :value="null"> Brez </option>
<option v-for="a in actions" :key="a.id" :value="a.id">{{ a.name }}</option>
</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>
<label class="block text-sm text-gray-700 mb-1">Privzeta odločitev</label>
<select v-model="form.default_decision_id" class="w-full rounded border-gray-300" :disabled="!form.default_action_id">
<option :value="null"> Najprej izberite dejanje </option>
<option v-for="d in filteredDecisions" :key="d.id" :value="d.id">{{ d.name }}</option>
</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>
<label class="block text-sm text-gray-700 mb-1">Predloga opombe aktivnosti</label>
<input type="text" v-model="form.activity_note_template" class="w-full rounded border-gray-300" />
<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 type="button" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="form.reset()">Ponastavi</button>
<button type="button" class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="form.processing" @click="submit">Shrani</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@ -69,3 +69,9 @@
$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'));
});

View File

@ -1,6 +1,8 @@
<?php
use App\Charts\ExampleChart;
use App\Http\Controllers\AccountBookingController;
use App\Http\Controllers\AccountPaymentController;
use App\Http\Controllers\CaseObjectController;
use App\Http\Controllers\ClientCaseContoller;
use App\Http\Controllers\ClientController;
@ -9,6 +11,7 @@
use App\Http\Controllers\FieldJobSettingController;
use App\Http\Controllers\ImportController;
use App\Http\Controllers\ImportTemplateController;
use App\Http\Controllers\PaymentSettingController;
use App\Http\Controllers\PersonController;
use App\Http\Controllers\PhoneViewController;
use App\Http\Controllers\SegmentController;
@ -224,6 +227,22 @@
// Route::put()
// types
// accounts / payments & bookings
Route::prefix('accounts/{account}')->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();