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
@@ -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"
@@ -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>
@@ -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>
+7
View File
@@ -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.",
@@ -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>