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
This commit is contained in:
parent
5f9d00b575
commit
9c6878d1bd
132
app/Http/Controllers/AccountInstallmentController.php
Normal file
132
app/Http/Controllers/AccountInstallmentController.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreInstallmentRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Booking;
|
||||
use App\Models\Installment;
|
||||
use App\Models\InstallmentSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class AccountInstallmentController extends Controller
|
||||
{
|
||||
public function list(Account $account): JsonResponse
|
||||
{
|
||||
$installments = Installment::query()
|
||||
->where('account_id', $account->id)
|
||||
->orderByDesc('installment_at')
|
||||
->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'installment_at', 'created_at'])
|
||||
->map(function (Installment $i) {
|
||||
return [
|
||||
'id' => $i->id,
|
||||
'amount' => (float) $i->amount,
|
||||
'balance_before' => (float) ($i->balance_before ?? 0),
|
||||
'currency' => $i->currency,
|
||||
'reference' => $i->reference,
|
||||
'installment_at' => optional($i->installment_at)?->toDateString(),
|
||||
'created_at' => optional($i->created_at)?->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'account' => [
|
||||
'id' => $account->id,
|
||||
'balance_amount' => $account->balance_amount,
|
||||
],
|
||||
'installments' => $installments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreInstallmentRequest $request, Account $account): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$amountCents = (int) round(((float) $validated['amount']) * 100);
|
||||
|
||||
$settings = InstallmentSetting::query()->first();
|
||||
$defaultCurrency = strtoupper($settings->default_currency ?? 'EUR');
|
||||
|
||||
$installment = Installment::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_before' => (float) ($account->balance_amount ?? 0),
|
||||
'amount' => (float) $validated['amount'],
|
||||
'currency' => strtoupper($validated['currency'] ?? $defaultCurrency),
|
||||
'reference' => $validated['reference'] ?? null,
|
||||
'installment_at' => $validated['installment_at'] ?? now(),
|
||||
'meta' => $validated['meta'] ?? null,
|
||||
'created_by' => $request->user()?->id,
|
||||
]);
|
||||
|
||||
// Debit booking — increases the account balance
|
||||
Booking::query()->create([
|
||||
'account_id' => $account->id,
|
||||
'payment_id' => null,
|
||||
'amount_cents' => $amountCents,
|
||||
'type' => 'debit',
|
||||
'description' => $installment->reference ? ('Obremenitev '.$installment->reference) : 'Obremenitev',
|
||||
'booked_at' => $installment->installment_at ?? now(),
|
||||
]);
|
||||
|
||||
if ($settings && ($settings->create_activity_on_installment ?? false)) {
|
||||
$note = $settings->activity_note_template ?? 'Dodan obrok';
|
||||
$note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $installment->currency], $note);
|
||||
|
||||
$account->refresh();
|
||||
$beforeStr = number_format((float) ($installment->balance_before ?? 0), 2, ',', '.').' '.$installment->currency;
|
||||
$afterStr = number_format((float) ($account->balance_amount ?? 0), 2, ',', '.').' '.$installment->currency;
|
||||
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: obrok)";
|
||||
|
||||
$account->loadMissing('contract');
|
||||
$clientCaseId = $account->contract?->client_case_id;
|
||||
if ($clientCaseId) {
|
||||
$activity = Activity::query()->create([
|
||||
'due_date' => null,
|
||||
'amount' => $amountCents / 100,
|
||||
'note' => $note,
|
||||
'action_id' => $settings->default_action_id,
|
||||
'decision_id' => $settings->default_decision_id,
|
||||
'client_case_id' => $clientCaseId,
|
||||
'contract_id' => $account->contract_id,
|
||||
]);
|
||||
$installment->update(['activity_id' => $activity->id]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Installment created.');
|
||||
}
|
||||
|
||||
public function destroy(Account $account, Installment $installment): RedirectResponse|JsonResponse
|
||||
{
|
||||
if ($installment->account_id !== $account->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Delete related debit booking(s) to revert balance via model events
|
||||
Booking::query()
|
||||
->where('account_id', $account->id)
|
||||
->where('type', 'debit')
|
||||
->whereDate('booked_at', optional($installment->installment_at)?->toDateString())
|
||||
->where('amount_cents', (int) round(((float) $installment->amount) * 100))
|
||||
->whereNull('payment_id')
|
||||
->get()
|
||||
->each->delete();
|
||||
|
||||
if ($installment->activity_id) {
|
||||
$activity = Activity::query()->find($installment->activity_id);
|
||||
if ($activity) {
|
||||
$activity->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$installment->delete();
|
||||
|
||||
if (request()->wantsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Installment deleted.');
|
||||
}
|
||||
}
|
||||
66
app/Http/Controllers/InstallmentSettingController.php
Normal file
66
app/Http/Controllers/InstallmentSettingController.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateInstallmentSettingRequest;
|
||||
use App\Models\Action;
|
||||
use App\Models\Decision;
|
||||
use App\Models\InstallmentSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class InstallmentSettingController extends Controller
|
||||
{
|
||||
public function edit(): Response
|
||||
{
|
||||
$setting = InstallmentSetting::query()->first();
|
||||
if (! $setting) {
|
||||
$setting = InstallmentSetting::query()->create([
|
||||
'default_currency' => 'EUR',
|
||||
'create_activity_on_installment' => false,
|
||||
'default_decision_id' => null,
|
||||
'default_action_id' => null,
|
||||
'activity_note_template' => 'Dodan obrok: {amount} {currency}',
|
||||
]);
|
||||
}
|
||||
|
||||
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||
$actions = Action::query()
|
||||
->with(['decisions:id'])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (Action $a) {
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'decision_ids' => $a->decisions->pluck('id')->values(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Settings/Installments/Index', [
|
||||
'setting' => [
|
||||
'id' => $setting->id,
|
||||
'default_currency' => $setting->default_currency,
|
||||
'create_activity_on_installment' => (bool) $setting->create_activity_on_installment,
|
||||
'default_decision_id' => $setting->default_decision_id,
|
||||
'default_action_id' => $setting->default_action_id,
|
||||
'activity_note_template' => $setting->activity_note_template,
|
||||
],
|
||||
'decisions' => $decisions,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateInstallmentSettingRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$setting = InstallmentSetting::query()->firstOrFail();
|
||||
|
||||
$data['create_activity_on_installment'] = (bool) ($data['create_activity_on_installment'] ?? false);
|
||||
|
||||
$setting->update($data);
|
||||
|
||||
return back()->with('success', 'Nastavitve shranjene.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
24
app/Http/Requests/StoreInstallmentRequest.php
Normal file
24
app/Http/Requests/StoreInstallmentRequest.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInstallmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'numeric', 'min:0.01'],
|
||||
'currency' => ['nullable', 'string', 'size:3'],
|
||||
'reference' => ['nullable', 'string', 'max:100'],
|
||||
'installment_at' => ['nullable', 'date'],
|
||||
'meta' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/UpdateInstallmentSettingRequest.php
Normal file
24
app/Http/Requests/UpdateInstallmentSettingRequest.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateInstallmentSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'default_currency' => ['required', 'string', 'size:3'],
|
||||
'create_activity_on_installment' => ['sometimes', 'boolean'],
|
||||
'default_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
'default_action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||
'activity_note_template' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
46
app/Models/Installment.php
Normal file
46
app/Models/Installment.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Installment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'amount',
|
||||
'balance_before',
|
||||
'currency',
|
||||
'reference',
|
||||
'installment_at',
|
||||
'meta',
|
||||
'created_by',
|
||||
'activity_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'installment_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
'amount' => 'decimal:4',
|
||||
'balance_before' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
public function activity(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Activity::class);
|
||||
}
|
||||
}
|
||||
19
app/Models/InstallmentSetting.php
Normal file
19
app/Models/InstallmentSetting.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InstallmentSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'default_currency',
|
||||
'create_activity_on_installment',
|
||||
'default_decision_id',
|
||||
'default_action_id',
|
||||
'activity_note_template',
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('installments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('account_id')->constrained('accounts')->cascadeOnDelete();
|
||||
$table->decimal('amount', 20, 4);
|
||||
$table->decimal('balance_before', 20, 4)->nullable();
|
||||
$table->string('currency', 3)->default('EUR');
|
||||
$table->string('reference', 100)->nullable();
|
||||
$table->timestamp('installment_at')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('activity_id')->nullable()->constrained('activities')->nullOnDelete();
|
||||
$table->softDeletes();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('installments');
|
||||
}
|
||||
};
|
||||
|
|
@ -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('installment_settings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('default_currency', 3)->default('EUR');
|
||||
$table->boolean('create_activity_on_installment')->default(false);
|
||||
$table->foreignId('default_decision_id')->nullable()->constrained('decisions')->nullOnDelete();
|
||||
$table->foreignId('default_action_id')->nullable()->constrained('actions')->nullOnDelete();
|
||||
$table->string('activity_note_template', 255)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('installment_settings');
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -357,11 +366,18 @@ function isActive(patterns) {
|
|||
<!-- Title -->
|
||||
<span
|
||||
v-if="!sidebarCollapsed"
|
||||
class="truncate transition-opacity"
|
||||
class="flex-1 truncate transition-opacity"
|
||||
:class="{ 'font-medium': isActive(item.active) }"
|
||||
>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="!sidebarCollapsed && getBadge(item) > 0"
|
||||
variant="destructive"
|
||||
class="ml-auto shrink-0 px-1.5 py-0.5 text-xs font-mono text-amber-50"
|
||||
>
|
||||
{{ getBadge(item) }}
|
||||
</Badge>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
|
|||
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
|
||||
import PaymentDialog from "./PaymentDialog.vue";
|
||||
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
|
||||
import InstallmentDialog from "./InstallmentDialog.vue";
|
||||
import ViewInstallmentsDialog from "./ViewInstallmentsDialog.vue";
|
||||
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
|
||||
|
|
@ -31,6 +33,7 @@ import {
|
|||
faSpinner,
|
||||
faTags,
|
||||
faFolderOpen,
|
||||
faArrowUp,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import EmptyState from "@/Components/EmptyState.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
|
|
@ -444,6 +447,52 @@ const closePaymentsDialog = () => {
|
|||
selectedContract.value = null;
|
||||
};
|
||||
|
||||
// Installments
|
||||
const showInstallmentDialog = ref(false);
|
||||
const installmentContract = ref(null);
|
||||
const installmentForm = useForm({
|
||||
amount: null,
|
||||
currency: "EUR",
|
||||
installment_at: null,
|
||||
reference: "",
|
||||
});
|
||||
|
||||
const openInstallmentDialog = (c) => {
|
||||
installmentContract.value = c;
|
||||
installmentForm.reset();
|
||||
installmentForm.installment_at = todayStr.value;
|
||||
showInstallmentDialog.value = true;
|
||||
};
|
||||
|
||||
const closeInstallmentDialog = () => {
|
||||
showInstallmentDialog.value = false;
|
||||
installmentContract.value = null;
|
||||
};
|
||||
|
||||
const submitInstallment = () => {
|
||||
if (!installmentContract.value?.account?.id) return;
|
||||
const accountId = installmentContract.value.account.id;
|
||||
installmentForm.post(route("accounts.installments.store", { account: accountId }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
closeInstallmentDialog();
|
||||
router.reload({ only: ["contracts", "activities"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showInstallmentsDialog = ref(false);
|
||||
|
||||
const openInstallmentsDialog = (c) => {
|
||||
selectedContract.value = c;
|
||||
showInstallmentsDialog.value = true;
|
||||
};
|
||||
|
||||
const closeInstallmentsDialog = () => {
|
||||
showInstallmentsDialog.value = false;
|
||||
selectedContract.value = null;
|
||||
};
|
||||
|
||||
// Meta edit dialog
|
||||
const showMetaEditDialog = ref(false);
|
||||
|
||||
|
|
@ -832,6 +881,26 @@ const availableSegmentsCount = computed(() => {
|
|||
@click="openPaymentDialog(row)"
|
||||
/>
|
||||
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
|
||||
<!-- Installments -->
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
Obroki
|
||||
</div>
|
||||
<ActionMenuItem
|
||||
:icon="faCircleInfo"
|
||||
label="Pokaži obroke"
|
||||
@click="openInstallmentsDialog(row)"
|
||||
/>
|
||||
<ActionMenuItem
|
||||
v-if="row.active && row?.account"
|
||||
:icon="faArrowUp"
|
||||
label="Dodaj obrok"
|
||||
@click="openInstallmentDialog(row)"
|
||||
/>
|
||||
|
||||
<!-- Archive -->
|
||||
<template v-if="edit">
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
|
|
@ -937,6 +1006,20 @@ const availableSegmentsCount = computed(() => {
|
|||
:edit="edit"
|
||||
/>
|
||||
|
||||
<InstallmentDialog
|
||||
:show="showInstallmentDialog"
|
||||
:form="installmentForm"
|
||||
@close="closeInstallmentDialog"
|
||||
@submit="submitInstallment"
|
||||
/>
|
||||
|
||||
<ViewInstallmentsDialog
|
||||
:show="showInstallmentsDialog"
|
||||
:contract="selectedContract"
|
||||
@close="closeInstallmentsDialog"
|
||||
:edit="edit"
|
||||
/>
|
||||
|
||||
<ContractMetaEditDialog
|
||||
:show="showMetaEditDialog"
|
||||
:client_case="client_case"
|
||||
|
|
|
|||
82
resources/js/Pages/Cases/Partials/InstallmentDialog.vue
Normal file
82
resources/js/Pages/Cases/Partials/InstallmentDialog.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<script setup>
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import CurrencyInput from "@/Components/CurrencyInput.vue";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import DatePicker from "@/Components/DatePicker.vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
form: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "submit"]);
|
||||
|
||||
const onClose = () => emit("close");
|
||||
const onSubmit = () => emit("submit");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreateDialog
|
||||
:show="show"
|
||||
title="Dodaj obrok"
|
||||
confirm-text="Shrani"
|
||||
:processing="form.processing"
|
||||
@close="onClose"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="installmentAmount">Znesek</Label>
|
||||
<CurrencyInput
|
||||
id="installmentAmount"
|
||||
v-model="form.amount"
|
||||
:precision="{ min: 0, max: 2 }"
|
||||
placeholder="0,00"
|
||||
class="w-full"
|
||||
/>
|
||||
<p v-if="form.errors?.amount" class="text-sm text-red-600">
|
||||
{{ form.errors.amount }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="installmentCurrency">Valuta</Label>
|
||||
<Input
|
||||
id="installmentCurrency"
|
||||
type="text"
|
||||
v-model="form.currency"
|
||||
maxlength="3"
|
||||
placeholder="EUR"
|
||||
/>
|
||||
<p v-if="form.errors?.currency" class="text-sm text-red-600">
|
||||
{{ form.errors.currency }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="installmentDate">Datum</Label>
|
||||
<DatePicker
|
||||
id="installmentDate"
|
||||
v-model="form.installment_at"
|
||||
format="dd.MM.yyyy"
|
||||
:error="form.errors?.installment_at"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="installmentReference">Sklic</Label>
|
||||
<Input
|
||||
id="installmentReference"
|
||||
type="text"
|
||||
v-model="form.reference"
|
||||
placeholder="Sklic"
|
||||
/>
|
||||
<p v-if="form.errors?.reference" class="text-sm text-red-600">
|
||||
{{ form.errors.reference }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CreateDialog>
|
||||
</template>
|
||||
160
resources/js/Pages/Cases/Partials/ViewInstallmentsDialog.vue
Normal file
160
resources/js/Pages/Cases/Partials/ViewInstallmentsDialog.vue
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<script setup>
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
contract: { type: Object, default: null },
|
||||
edit: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const installments = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const contractRef = computed(() => props.contract?.reference || "—");
|
||||
const accountId = computed(() => props.contract?.account?.id || null);
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return "-";
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt.getTime()) ? "-" : dt.toLocaleDateString("de");
|
||||
}
|
||||
|
||||
async function loadInstallments() {
|
||||
if (!accountId.value) {
|
||||
installments.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
route("accounts.installments.list", { account: accountId.value })
|
||||
);
|
||||
installments.value = data.installments || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("close");
|
||||
installments.value = [];
|
||||
}
|
||||
|
||||
function deleteInstallment(installmentId) {
|
||||
if (!accountId.value) return;
|
||||
router.delete(
|
||||
route("accounts.installments.destroy", {
|
||||
account: accountId.value,
|
||||
installment: installmentId,
|
||||
}),
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
only: ["contracts", "activities"],
|
||||
onSuccess: async () => {
|
||||
await loadInstallments();
|
||||
},
|
||||
onError: async () => {
|
||||
await loadInstallments();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (visible) => {
|
||||
if (visible) {
|
||||
await loadInstallments();
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.contract?.account?.id,
|
||||
async () => {
|
||||
if (props.show) {
|
||||
await loadInstallments();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>
|
||||
Obroki za pogodbo <span class="text-gray-600">{{ contractRef }}</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div>
|
||||
<div v-if="loading" class="text-sm text-gray-500">Nalaganje…</div>
|
||||
<template v-else>
|
||||
<div v-if="installments.length === 0" class="text-sm text-gray-500">Ni obrokov.</div>
|
||||
<div v-else class="divide-y divide-gray-100 border rounded">
|
||||
<div
|
||||
v-for="i in installments"
|
||||
:key="i.id"
|
||||
class="px-3 py-2 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm text-gray-800">
|
||||
{{
|
||||
Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: i.currency || "EUR",
|
||||
}).format(i.amount ?? 0)
|
||||
}}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span>{{ formatDate(i.installment_at) }}</span>
|
||||
<span v-if="i.reference" class="ml-2">Sklic: {{ i.reference }}</span>
|
||||
<span v-if="i.balance_before !== undefined" class="ml-2">
|
||||
Stanje pred:
|
||||
{{
|
||||
Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: i.currency || "EUR",
|
||||
}).format(i.balance_before ?? 0)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" v-if="edit">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
|
||||
@click="deleteInstallment(i.id)"
|
||||
title="Izbriši obrok"
|
||||
>
|
||||
<span class="text-sm">Briši</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||
@click="loadInstallments"
|
||||
>
|
||||
Osveži
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
@click="close"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Archive,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
CalendarDays,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const settingsCards = [
|
||||
|
|
@ -27,6 +28,12 @@ const settingsCards = [
|
|||
route: "settings.payment.edit",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
title: "Installments",
|
||||
description: "Defaults for installments and auto-activity.",
|
||||
route: "settings.installment.edit",
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
title: "Workflow",
|
||||
description: "Configure actions and decisions relationships.",
|
||||
|
|
|
|||
167
resources/js/Pages/Settings/Installments/Index.vue
Normal file
167
resources/js/Pages/Settings/Installments/Index.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import { computed, watch } from "vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import CardTitle from "@/Components/ui/card/CardTitle.vue";
|
||||
import { CalendarDays } from "lucide-vue-next";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
const props = defineProps({
|
||||
setting: Object,
|
||||
decisions: Array,
|
||||
actions: Array,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
default_currency: props.setting?.default_currency ?? "EUR",
|
||||
create_activity_on_installment: !!props.setting?.create_activity_on_installment,
|
||||
default_action_id: props.setting?.default_action_id ?? null,
|
||||
default_decision_id: props.setting?.default_decision_id ?? null,
|
||||
activity_note_template:
|
||||
props.setting?.activity_note_template ?? "Dodan obrok: {amount} {currency}",
|
||||
});
|
||||
|
||||
const filteredDecisions = computed(() => {
|
||||
const actionId = form.default_action_id;
|
||||
if (!actionId) return [];
|
||||
const action = props.actions?.find((a) => a.id === actionId);
|
||||
if (!action || !action.decision_ids) return [];
|
||||
const ids = new Set(action.decision_ids);
|
||||
return (props.decisions || []).filter((d) => ids.has(d.id));
|
||||
});
|
||||
|
||||
watch(
|
||||
() => form.default_action_id,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
form.default_decision_id = null;
|
||||
} else {
|
||||
const ids = new Set((filteredDecisions.value || []).map((d) => d.id));
|
||||
if (!ids.has(form.default_decision_id)) {
|
||||
form.default_decision_id = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
form.put(route("settings.installment.update"), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Nastavitve obrokov">
|
||||
<template #header></template>
|
||||
<div class="max-w-3xl mx-auto p-6">
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-0"
|
||||
header-class="py-3! px-4 gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<CalendarDays :size="18" />
|
||||
<CardTitle class="uppercase">Nastavitve obrokov</CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-6 p-4 border-t">
|
||||
<div>
|
||||
<InputLabel for="currency">Privzeta valuta</InputLabel>
|
||||
<Input
|
||||
id="currency"
|
||||
v-model="form.default_currency"
|
||||
maxlength="3"
|
||||
class="w-40"
|
||||
/>
|
||||
<div v-if="form.errors.default_currency" class="text-sm text-red-600 mt-1">
|
||||
{{ form.errors.default_currency }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="create-activity" v-model="form.create_activity_on_installment" />
|
||||
<InputLabel for="create-activity" class="text-sm font-normal cursor-pointer">
|
||||
Ustvari aktivnost ob dodanem obroku
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<InputLabel for="default-action">Privzeto dejanje</InputLabel>
|
||||
<Select v-model="form.default_action_id">
|
||||
<SelectTrigger id="default-action">
|
||||
<SelectValue placeholder="— Brez —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Brez —</SelectItem>
|
||||
<SelectItem v-for="a in actions" :key="a.id" :value="a.id">{{
|
||||
a.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="form.errors.default_action_id" class="text-sm text-red-600 mt-1">
|
||||
{{ form.errors.default_action_id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel for="default-decision">Privzeta odločitev</InputLabel>
|
||||
<Select
|
||||
v-model="form.default_decision_id"
|
||||
:disabled="!form.default_action_id"
|
||||
>
|
||||
<SelectTrigger id="default-decision">
|
||||
<SelectValue placeholder="— Najprej izberite dejanje —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Najprej izberite dejanje —</SelectItem>
|
||||
<SelectItem v-for="d in filteredDecisions" :key="d.id" :value="d.id">{{
|
||||
d.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div
|
||||
v-if="form.errors.default_decision_id"
|
||||
class="text-sm text-red-600 mt-1"
|
||||
>
|
||||
{{ form.errors.default_decision_id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel for="note-template">Predloga opombe aktivnosti</InputLabel>
|
||||
<Input id="note-template" v-model="form.activity_note_template" />
|
||||
<p class="text-xs text-gray-500 mt-1">Podprti žetoni: {amount}, {currency}</p>
|
||||
<div
|
||||
v-if="form.errors.activity_note_template"
|
||||
class="text-sm text-red-600 mt-1"
|
||||
>
|
||||
{{ form.errors.activity_note_template }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="form.reset()">Ponastavi</Button>
|
||||
<Button @click="submit" :disabled="form.processing">Shrani</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AccountBookingController;
|
||||
use App\Http\Controllers\AccountInstallmentController;
|
||||
use App\Http\Controllers\AccountPaymentController;
|
||||
use App\Http\Controllers\ActivityNotificationController;
|
||||
use App\Http\Controllers\ArchiveSettingController;
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
use App\Http\Controllers\FieldJobSettingController;
|
||||
use App\Http\Controllers\ImportController;
|
||||
use App\Http\Controllers\ImportTemplateController;
|
||||
use App\Http\Controllers\InstallmentSettingController;
|
||||
use App\Http\Controllers\NotificationController;
|
||||
use App\Http\Controllers\PaymentSettingController;
|
||||
use App\Http\Controllers\PersonController;
|
||||
|
|
@ -502,12 +504,20 @@
|
|||
Route::get('bookings', [AccountBookingController::class, 'index'])->name('bookings.index');
|
||||
Route::post('bookings', [AccountBookingController::class, 'store'])->name('bookings.store');
|
||||
Route::delete('bookings/{booking}', [AccountBookingController::class, 'destroy'])->name('bookings.destroy');
|
||||
|
||||
Route::get('installments/list', [AccountInstallmentController::class, 'list'])->name('installments.list');
|
||||
Route::post('installments', [AccountInstallmentController::class, 'store'])->name('installments.store');
|
||||
Route::delete('installments/{installment}', [AccountInstallmentController::class, 'destroy'])->name('installments.destroy');
|
||||
});
|
||||
|
||||
// settings - payment settings
|
||||
Route::get('settings/payment', [PaymentSettingController::class, 'edit'])->name('settings.payment.edit');
|
||||
Route::put('settings/payment', [PaymentSettingController::class, 'update'])->name('settings.payment.update');
|
||||
|
||||
// settings - installment settings
|
||||
Route::get('settings/installment', [InstallmentSettingController::class, 'edit'])->name('settings.installment.edit');
|
||||
Route::put('settings/installment', [InstallmentSettingController::class, 'update'])->name('settings.installment.update');
|
||||
|
||||
Route::get('types/address', function (Request $request) {
|
||||
$types = App\Models\Person\AddressType::all();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user