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:
Simon Pocrnjič 2026-03-11 21:04:20 +01:00
parent 5f9d00b575
commit 9c6878d1bd
17 changed files with 910 additions and 2 deletions

View 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.');
}
}

View 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.');
}
}

View File

@ -59,6 +59,15 @@ public function share(Request $request): array
'info' => fn () => $request->session()->get('info'), 'info' => fn () => $request->session()->get('info'),
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling '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) { 'notifications' => function () use ($request) {
try { try {
$user = $request->user(); $user = $request->user();

View 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'],
];
}
}

View 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'],
];
}
}

View File

@ -10,9 +10,10 @@
class Account extends Model class Account extends Model
{ {
use HasFactory;
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */ /** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes; use SoftDeletes;
use HasFactory;
protected $fillable = [ protected $fillable = [
'reference', 'reference',
@ -58,6 +59,11 @@ public function payments(): HasMany
return $this->hasMany(\App\Models\Payment::class); return $this->hasMany(\App\Models\Payment::class);
} }
public function installments(): HasMany
{
return $this->hasMany(\App\Models\Installment::class);
}
public function bookings(): HasMany public function bookings(): HasMany
{ {
return $this->hasMany(\App\Models\Booking::class); return $this->hasMany(\App\Models\Booking::class);

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

View 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',
];
}

View File

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

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

View File

@ -28,6 +28,7 @@ import { SmartphoneIcon } from "lucide-vue-next";
import { TabletSmartphoneIcon } from "lucide-vue-next"; import { TabletSmartphoneIcon } from "lucide-vue-next";
import { PhoneCallIcon } from "lucide-vue-next"; import { PhoneCallIcon } from "lucide-vue-next";
import { PackageIcon } from "lucide-vue-next"; import { PackageIcon } from "lucide-vue-next";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({ const props = defineProps({
title: String, title: String,
@ -284,6 +285,14 @@ function isActive(patterns) {
return false; return false;
} }
} }
function getBadge(item) {
if (item.key === "call-laters") {
return page.props.callLaterCount || 0;
}
return 0;
}
</script> </script>
<template> <template>
@ -357,11 +366,18 @@ function isActive(patterns) {
<!-- Title --> <!-- Title -->
<span <span
v-if="!sidebarCollapsed" v-if="!sidebarCollapsed"
class="truncate transition-opacity" class="flex-1 truncate transition-opacity"
:class="{ 'font-medium': isActive(item.active) }" :class="{ 'font-medium': isActive(item.active) }"
> >
{{ item.title }} {{ item.title }}
</span> </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> </Link>
</li> </li>
</ul> </ul>

View File

@ -15,6 +15,8 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
import CaseObjectsDialog from "./CaseObjectsDialog.vue"; import CaseObjectsDialog from "./CaseObjectsDialog.vue";
import PaymentDialog from "./PaymentDialog.vue"; import PaymentDialog from "./PaymentDialog.vue";
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue"; import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
import InstallmentDialog from "./InstallmentDialog.vue";
import ViewInstallmentsDialog from "./ViewInstallmentsDialog.vue";
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue"; import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue"; import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue"; import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
@ -31,6 +33,7 @@ import {
faSpinner, faSpinner,
faTags, faTags,
faFolderOpen, faFolderOpen,
faArrowUp,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import EmptyState from "@/Components/EmptyState.vue"; import EmptyState from "@/Components/EmptyState.vue";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
@ -444,6 +447,52 @@ const closePaymentsDialog = () => {
selectedContract.value = null; 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 // Meta edit dialog
const showMetaEditDialog = ref(false); const showMetaEditDialog = ref(false);
@ -832,6 +881,26 @@ const availableSegmentsCount = computed(() => {
@click="openPaymentDialog(row)" @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 --> <!-- Archive -->
<template v-if="edit"> <template v-if="edit">
<div class="my-1 border-t border-gray-100" /> <div class="my-1 border-t border-gray-100" />
@ -937,6 +1006,20 @@ const availableSegmentsCount = computed(() => {
:edit="edit" :edit="edit"
/> />
<InstallmentDialog
:show="showInstallmentDialog"
:form="installmentForm"
@close="closeInstallmentDialog"
@submit="submitInstallment"
/>
<ViewInstallmentsDialog
:show="showInstallmentsDialog"
:contract="selectedContract"
@close="closeInstallmentsDialog"
:edit="edit"
/>
<ContractMetaEditDialog <ContractMetaEditDialog
:show="showMetaEditDialog" :show="showMetaEditDialog"
:client_case="client_case" :client_case="client_case"

View 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>

View 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>

View File

@ -12,6 +12,7 @@ import {
Archive, Archive,
ArrowRight, ArrowRight,
BarChart3, BarChart3,
CalendarDays,
} from "lucide-vue-next"; } from "lucide-vue-next";
const settingsCards = [ const settingsCards = [
@ -27,6 +28,12 @@ const settingsCards = [
route: "settings.payment.edit", route: "settings.payment.edit",
icon: CreditCard, icon: CreditCard,
}, },
{
title: "Installments",
description: "Defaults for installments and auto-activity.",
route: "settings.installment.edit",
icon: CalendarDays,
},
{ {
title: "Workflow", title: "Workflow",
description: "Configure actions and decisions relationships.", description: "Configure actions and decisions relationships.",

View 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>

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\AccountBookingController; use App\Http\Controllers\AccountBookingController;
use App\Http\Controllers\AccountInstallmentController;
use App\Http\Controllers\AccountPaymentController; use App\Http\Controllers\AccountPaymentController;
use App\Http\Controllers\ActivityNotificationController; use App\Http\Controllers\ActivityNotificationController;
use App\Http\Controllers\ArchiveSettingController; use App\Http\Controllers\ArchiveSettingController;
@ -12,6 +13,7 @@
use App\Http\Controllers\FieldJobSettingController; use App\Http\Controllers\FieldJobSettingController;
use App\Http\Controllers\ImportController; use App\Http\Controllers\ImportController;
use App\Http\Controllers\ImportTemplateController; use App\Http\Controllers\ImportTemplateController;
use App\Http\Controllers\InstallmentSettingController;
use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationController;
use App\Http\Controllers\PaymentSettingController; use App\Http\Controllers\PaymentSettingController;
use App\Http\Controllers\PersonController; use App\Http\Controllers\PersonController;
@ -502,12 +504,20 @@
Route::get('bookings', [AccountBookingController::class, 'index'])->name('bookings.index'); Route::get('bookings', [AccountBookingController::class, 'index'])->name('bookings.index');
Route::post('bookings', [AccountBookingController::class, 'store'])->name('bookings.store'); Route::post('bookings', [AccountBookingController::class, 'store'])->name('bookings.store');
Route::delete('bookings/{booking}', [AccountBookingController::class, 'destroy'])->name('bookings.destroy'); 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 // settings - payment settings
Route::get('settings/payment', [PaymentSettingController::class, 'edit'])->name('settings.payment.edit'); Route::get('settings/payment', [PaymentSettingController::class, 'edit'])->name('settings.payment.edit');
Route::put('settings/payment', [PaymentSettingController::class, 'update'])->name('settings.payment.update'); 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) { Route::get('types/address', function (Request $request) {
$types = App\Models\Person\AddressType::all(); $types = App\Models\Person\AddressType::all();