changes UI

This commit is contained in:
Simon Pocrnjič 2025-11-04 18:53:23 +01:00
parent fd9f26d82a
commit b7fa2d261b
15 changed files with 911 additions and 730 deletions

View File

@ -749,30 +749,12 @@ public function show(ClientCase $clientCase)
$perPage = request()->integer('contracts_per_page', 50); $perPage = request()->integer('contracts_per_page', 50);
$contracts = $contractsQuery->paginate($perPage, ['*'], 'contracts_page')->withQueryString(); $contracts = $contractsQuery->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
// TEMP DEBUG: log what balances are being sent to Inertia (remove once issue resolved) // Prepare contract reference map from paginated contracts
try {
logger()->info('Show contracts balances', [
'case_id' => $case->id,
'contract_count' => $contracts->count(),
'contracts' => $contracts->map(fn ($c) => [
'id' => $c->id,
'uuid' => $c->uuid,
'reference' => $c->reference,
'account_id' => optional($c->account)->id,
'initial_amount' => optional($c->account)->initial_amount,
'balance_amount' => optional($c->account)->balance_amount,
'account_updated_at' => optional($c->account)->updated_at,
])->toArray(),
]);
} catch (\Throwable $e) {
// swallow
}
// Prepare contract reference and UUID maps from paginated contracts
$contractItems = $contracts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator $contractItems = $contracts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator
? $contracts->items() ? $contracts->items()
: $contracts->all(); : $contracts->all();
$contractRefMap = []; $contractRefMap = [];
$contractUuidMap = []; $contractUuidMap = [];
foreach ($contractItems as $c) { foreach ($contractItems as $c) {
@ -791,15 +773,6 @@ public function show(ClientCase $clientCase)
// Load initial batch of documents (limit to reduce payload size) // Load initial batch of documents (limit to reduce payload size)
$contractDocs = collect(); $contractDocs = collect();
if ($contractIds->isNotEmpty()) { if ($contractIds->isNotEmpty()) {
// Build UUID map for all contracts (including trashed) to avoid N+1 queries
$allContractUuids = Contract::withTrashed()
->whereIn('id', $contractIds->all())
->pluck('uuid', 'id')
->toArray();
// Merge with contracts already loaded
$contractUuidMap = array_merge($contractUuidMap, $allContractUuids);
$contractDocs = Document::query() $contractDocs = Document::query()
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public']) ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
->where('documentable_type', Contract::class) ->where('documentable_type', Contract::class)

View File

@ -95,6 +95,7 @@ watch(
return return
} }
// When dialog opens, reset form with document values // When dialog opens, reset form with document values
console.log((props.document?.documentable_type || '').toLowerCase().includes('contract') ? (props.document.contract_uuid || null) : null, props.document)
if (props.document) { if (props.document) {
form.resetForm({ form.resetForm({
values: { values: {

View File

@ -9,7 +9,7 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/Comp
import { Input } from '@/Components/ui/input' import { Input } from '@/Components/ui/input'
import { Textarea } from '@/Components/ui/textarea' import { Textarea } from '@/Components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'
import { Checkbox } from '@/Components/ui/checkbox' import { Switch } from '@/Components/ui/switch'
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
@ -203,9 +203,9 @@ const onConfirm = () => {
<FormField v-slot="{ value, handleChange }" name="is_public"> <FormField v-slot="{ value, handleChange }" name="is_public">
<FormItem class="flex flex-row items-start space-x-3 space-y-0"> <FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
<Checkbox <Switch
:checked="value" :model-value="value"
@update:checked="handleChange" @update:model-value="handleChange"
/> />
</FormControl> </FormControl>
<div class="space-y-1 leading-none"> <div class="space-y-1 leading-none">

View File

@ -14,7 +14,7 @@ import {
FormMessage, FormMessage,
} from "@/Components/ui/form"; } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Checkbox } from "@/Components/ui/checkbox"; import { Switch } from "@/Components/ui/switch";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
@ -216,9 +216,9 @@ const onConfirm = () => {
> >
<FormItem class="flex flex-row items-start space-x-3 space-y-0"> <FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
<Checkbox <Switch
:checked="value" :model-value="value"
@update:checked="handleChange" @update:model-value="handleChange"
/> />
</FormControl> </FormControl>
<div class="space-y-1 leading-none"> <div class="space-y-1 leading-none">

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons"; import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue"; import Dropdown from "../Dropdown.vue";
const props = defineProps({ const props = defineProps({
@ -15,17 +15,7 @@ const handleDelete = (id, label) => emit('delete', id, label);
</script> </script>
<template> <template>
<div class="flex justify-end mb-3" v-if="edit"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<button
@click="handleAdd"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
title="Dodaj naslov"
>
<PlusIcon size="sm" />
<span>Dodaj naslov</span>
</button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<div <div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow" class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="address in person.addresses" v-for="address in person.addresses"
@ -80,6 +70,14 @@ const handleDelete = (id, label) => emit('delete', id, label);
}} }}
</p> </p>
</div> </div>
<button
v-if="edit"
@click="handleAdd"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
title="Dodaj naslov"
>
<PlusIcon class="h-8 w-8 text-gray-400" />
</button>
</div> </div>
</template> </template>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons"; import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue"; import Dropdown from "../Dropdown.vue";
const props = defineProps({ const props = defineProps({
@ -17,17 +17,7 @@ const handleDelete = (id, label) => emit('delete', id, label);
</script> </script>
<template> <template>
<div class="flex justify-end mb-3" v-if="edit"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<button
@click="handleAdd"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
title="Dodaj email"
>
<PlusIcon size="sm" />
<span>Dodaj email</span>
</button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<template v-if="getEmails(person).length"> <template v-if="getEmails(person).length">
<div <div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow" class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
@ -89,7 +79,15 @@ const handleDelete = (id, label) => emit('delete', id, label);
</p> </p>
</div> </div>
</template> </template>
<p v-else class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200"> <button
v-if="edit"
@click="handleAdd"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
title="Dodaj email"
>
<PlusIcon class="h-8 w-8 text-gray-400" />
</button>
<p v-else-if="!edit && !getEmails(person).length" class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
Ni e-poštnih naslovov. Ni e-poštnih naslovov.
</p> </p>
</div> </div>

View File

@ -2,6 +2,8 @@
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import { Button } from "@/Components/ui/button";
import { PlusIcon } from "@/Utilities/Icons";
import PersonUpdateForm from "./PersonUpdateForm.vue"; import PersonUpdateForm from "./PersonUpdateForm.vue";
import AddressCreateForm from "./AddressCreateForm.vue"; import AddressCreateForm from "./AddressCreateForm.vue";
import AddressUpdateForm from "./AddressUpdateForm.vue"; import AddressUpdateForm from "./AddressUpdateForm.vue";
@ -20,7 +22,6 @@ import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue"; import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue"; import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue"; import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
import Separator from "../ui/separator/Separator.vue";
const props = defineProps({ const props = defineProps({
person: Object, person: Object,
@ -95,8 +96,12 @@ const openDrawerAddAddress = (edit = false, id = 0) => {
const closeDrawerAddAddress = () => { const closeDrawerAddAddress = () => {
drawerAddAddress.value = false; drawerAddAddress.value = false;
const wasEdit = editAddress.value;
editAddress.value = false; editAddress.value = false;
editAddressId.value = 0; editAddressId.value = 0;
if (!wasEdit) {
switchToTab('addresses');
}
}; };
// Phone handlers // Phone handlers
@ -111,8 +116,12 @@ const operDrawerAddPhone = openDrawerAddPhone;
const closeDrawerAddPhone = () => { const closeDrawerAddPhone = () => {
drawerAddPhone.value = false; drawerAddPhone.value = false;
const wasEdit = editPhone.value;
editPhone.value = false; editPhone.value = false;
editPhoneId.value = 0; editPhoneId.value = 0;
if (!wasEdit) {
switchToTab('phones');
}
}; };
// Email handlers // Email handlers
@ -122,6 +131,16 @@ const openDrawerAddEmail = (edit = false, id = 0) => {
editEmailId.value = id; editEmailId.value = id;
}; };
const closeDrawerAddEmail = () => {
drawerAddEmail.value = false;
const wasEdit = editEmail.value;
editEmail.value = false;
editEmailId.value = 0;
if (!wasEdit) {
switchToTab('emails');
}
};
// TRR handlers // TRR handlers
const openDrawerAddTrr = (edit = false, id = 0) => { const openDrawerAddTrr = (edit = false, id = 0) => {
drawerAddTrr.value = true; drawerAddTrr.value = true;
@ -129,6 +148,16 @@ const openDrawerAddTrr = (edit = false, id = 0) => {
editTrrId.value = id; editTrrId.value = id;
}; };
const closeDrawerAddTrr = () => {
drawerAddTrr.value = false;
const wasEdit = editTrr.value;
editTrr.value = false;
editTrrId.value = 0;
if (!wasEdit) {
switchToTab('trr');
}
};
// Confirm dialog handlers // Confirm dialog handlers
const openConfirm = (type, id, label = "") => { const openConfirm = (type, id, label = "") => {
confirm.value = { confirm.value = {
@ -258,10 +287,16 @@ const trrsCount = computed(() => {
const formatBadgeCount = (count) => { const formatBadgeCount = (count) => {
return count >= 999 ? '999+' : String(count); return count >= 999 ? '999+' : String(count);
}; };
// Tab switching
const activeTab = ref('person');
const switchToTab = (tab) => {
activeTab.value = tab;
};
</script> </script>
<template> <template>
<Tabs default-value="person" class="mt-2"> <Tabs v-model="activeTab" class="mt-2">
<TabsList class="flex w-full bg-white gap-2 p-1"> <TabsList class="flex w-full bg-white gap-2 p-1">
<TabsTrigger value="person" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2">Oseba</TabsTrigger> <TabsTrigger value="person" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2">Oseba</TabsTrigger>
<TabsTrigger value="addresses" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3"> <TabsTrigger value="addresses" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
@ -403,14 +438,14 @@ const formatBadgeCount = (count) => {
<!-- Email Dialogs --> <!-- Email Dialogs -->
<EmailCreateForm <EmailCreateForm
:show="drawerAddEmail && !editEmail" :show="drawerAddEmail && !editEmail"
@close="drawerAddEmail = false" @close="closeDrawerAddEmail"
:person="person" :person="person"
:types="types.email_types ?? []" :types="types.email_types ?? []"
:is-client-context="!!person?.client" :is-client-context="!!person?.client"
/> />
<EmailUpdateForm <EmailUpdateForm
:show="drawerAddEmail && editEmail" :show="drawerAddEmail && editEmail"
@close="drawerAddEmail = false" @close="closeDrawerAddEmail"
:person="person" :person="person"
:types="types.email_types ?? []" :types="types.email_types ?? []"
:id="editEmailId" :id="editEmailId"
@ -420,7 +455,7 @@ const formatBadgeCount = (count) => {
<!-- TRR Dialogs --> <!-- TRR Dialogs -->
<TrrCreateForm <TrrCreateForm
:show="drawerAddTrr && !editTrr" :show="drawerAddTrr && !editTrr"
@close="drawerAddTrr = false" @close="closeDrawerAddTrr"
:person="person" :person="person"
:types="types.trr_types ?? []" :types="types.trr_types ?? []"
:banks="types.banks ?? []" :banks="types.banks ?? []"
@ -428,7 +463,7 @@ const formatBadgeCount = (count) => {
/> />
<TrrUpdateForm <TrrUpdateForm
:show="drawerAddTrr && editTrr" :show="drawerAddTrr && editTrr"
@close="drawerAddTrr = false" @close="closeDrawerAddTrr"
:person="person" :person="person"
:types="types.trr_types ?? []" :types="types.trr_types ?? []"
:banks="types.banks ?? []" :banks="types.banks ?? []"

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons"; import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue"; import Dropdown from "../Dropdown.vue";
const props = defineProps({ const props = defineProps({
@ -19,18 +19,7 @@ const handleSms = (phone) => emit('sms', phone);
</script> </script>
<template> <template>
<div class="flex justify-end mb-3" v-if="edit"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<button
type="button"
@click="handleAdd"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
title="Dodaj telefon"
>
<PlusIcon size="sm" />
<span>Dodaj telefon</span>
</button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<template v-if="getPhones(person).length"> <template v-if="getPhones(person).length">
<div <div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow" class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
@ -90,7 +79,15 @@ const handleSms = (phone) => emit('sms', phone);
<p class="text-sm font-medium text-gray-900 leading-relaxed">{{ phone.nu }}</p> <p class="text-sm font-medium text-gray-900 leading-relaxed">{{ phone.nu }}</p>
</div> </div>
</template> </template>
<p v-else class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200"> <button
v-if="edit"
@click="handleAdd"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
title="Dodaj telefon"
>
<PlusIcon class="h-8 w-8 text-gray-400" />
</button>
<p v-else-if="!edit && !getPhones(person).length" class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
Ni telefonov. Ni telefonov.
</p> </p>
</div> </div>

View File

@ -1,7 +1,34 @@
<script setup> <script setup>
import { ref, watch, computed } from "vue"; import { ref, watch, computed } from "vue";
import DialogModal from "@/Components/DialogModal.vue"; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { router, usePage } from "@inertiajs/vue3"; import { router, usePage } from "@inertiajs/vue3";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Switch } from "@/Components/ui/switch";
import { Button } from "@/Components/ui/button";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
@ -12,15 +39,11 @@ const props = defineProps({
smsTemplates: { type: Array, default: () => [] }, smsTemplates: { type: Array, default: () => [] },
}); });
const emit = defineEmits(['close']); const emit = defineEmits(["close"]);
// SMS dialog state
const smsMessage = ref("");
const smsSending = ref(false);
// Page-level props fallback for SMS metadata
const page = usePage(); const page = usePage();
const pageProps = computed(() => page?.props ?? {}); const pageProps = computed(() => page?.props ?? {});
const pageSmsProfiles = computed(() => { const pageSmsProfiles = computed(() => {
const fromProps = const fromProps =
Array.isArray(props.smsProfiles) && props.smsProfiles.length Array.isArray(props.smsProfiles) && props.smsProfiles.length
@ -28,11 +51,13 @@ const pageSmsProfiles = computed(() => {
: null; : null;
return fromProps ?? pageProps.value?.sms_profiles ?? []; return fromProps ?? pageProps.value?.sms_profiles ?? [];
}); });
const pageSmsSenders = computed(() => { const pageSmsSenders = computed(() => {
const fromProps = const fromProps =
Array.isArray(props.smsSenders) && props.smsSenders.length ? props.smsSenders : null; Array.isArray(props.smsSenders) && props.smsSenders.length ? props.smsSenders : null;
return fromProps ?? pageProps.value?.sms_senders ?? []; return fromProps ?? pageProps.value?.sms_senders ?? [];
}); });
const pageSmsTemplates = computed(() => { const pageSmsTemplates = computed(() => {
const fromProps = const fromProps =
Array.isArray(props.smsTemplates) && props.smsTemplates.length Array.isArray(props.smsTemplates) && props.smsTemplates.length
@ -41,7 +66,32 @@ const pageSmsTemplates = computed(() => {
return fromProps ?? pageProps.value?.sms_templates ?? []; return fromProps ?? pageProps.value?.sms_templates ?? [];
}); });
// Helpers: EU formatter and token renderer // SMS encoding helpers
const GSM7_EXTENDED = new Set(["^", "{", "}", "\\", "[", "~", "]", "|"]);
const isGsm7 = (text) => {
for (const ch of text || "") {
if (ch === "€") continue;
const code = ch.charCodeAt(0);
if (code >= 0x80) return false;
}
return true;
};
const gsm7Length = (text) => {
let len = 0;
for (const ch of text || "") {
if (ch === "€" || GSM7_EXTENDED.has(ch)) {
len += 2;
} else {
len += 1;
}
}
return len;
};
const ucs2Length = (text) => (text ? text.length : 0);
const formatEu = (value, decimals = 2) => { const formatEu = (value, decimals = 2) => {
if (value === null || value === undefined || value === "") { if (value === null || value === undefined || value === "") {
return new Intl.NumberFormat("de-DE", { return new Intl.NumberFormat("de-DE", {
@ -81,52 +131,6 @@ const renderTokens = (text, vars) => {
}); });
}; };
// SMS length, encoding and credits
const GSM7_EXTENDED = new Set(["^", "{", "}", "\\", "[", "~", "]", "|"]);
const isGsm7 = (text) => {
for (const ch of text || "") {
if (ch === "€") continue;
const code = ch.charCodeAt(0);
if (code >= 0x80) return false;
}
return true;
};
const gsm7Length = (text) => {
let len = 0;
for (const ch of text || "") {
if (ch === "€" || GSM7_EXTENDED.has(ch)) {
len += 2;
} else {
len += 1;
}
}
return len;
};
const ucs2Length = (text) => (text ? text.length : 0);
const smsEncoding = computed(() => (isGsm7(smsMessage.value) ? "GSM-7" : "UCS-2"));
const charCount = computed(() =>
smsEncoding.value === "GSM-7"
? gsm7Length(smsMessage.value)
: ucs2Length(smsMessage.value)
);
const perSegment = computed(() => {
const count = charCount.value;
if (smsEncoding.value === "GSM-7") {
return count <= 160 ? 160 : 153;
}
return count <= 70 ? 70 : 67;
});
const segments = computed(() => {
const count = charCount.value;
const size = perSegment.value || 1;
return count > 0 ? Math.ceil(count / size) : 0;
});
const creditsNeeded = computed(() => segments.value);
const maxAllowed = computed(() => (smsEncoding.value === "GSM-7" ? 640 : 320));
const remaining = computed(() => Math.max(0, maxAllowed.value - charCount.value));
const truncateToLimit = (text, limit, encoding) => { const truncateToLimit = (text, limit, encoding) => {
if (!text) return ""; if (!text) return "";
if (limit <= 0) return ""; if (limit <= 0) return "";
@ -144,42 +148,137 @@ const truncateToLimit = (text, limit, encoding) => {
return out; return out;
}; };
watch(smsMessage, (val) => { // Form schema with custom validation
const limit = maxAllowed.value; const formSchema = toTypedSchema(
if (charCount.value > limit) { z.object({
smsMessage.value = truncateToLimit(val, limit, smsEncoding.value); message: z
} .string()
.min(1, "Vsebina sporočila je obvezna.")
.refine(
(val) => {
const encoding = isGsm7(val) ? "GSM-7" : "UCS-2";
const maxAllowed = encoding === "GSM-7" ? 640 : 320;
const count =
encoding === "GSM-7" ? gsm7Length(val) : ucs2Length(val);
return count <= maxAllowed;
},
{
message: "Sporočilo presega dovoljeno dolžino.",
}
),
template_id: z.number().nullable().optional(),
contract_uuid: z.string().nullable().optional(),
profile_id: z.number().nullable().optional(),
sender_id: z.number().nullable().optional(),
delivery_report: z.boolean().default(false),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
message: "",
template_id: null,
contract_uuid: null,
profile_id: null,
sender_id: null,
delivery_report: false,
},
}); });
const processing = ref(false);
const contractsForCase = ref([]); const contractsForCase = ref([]);
const selectedContractUuid = ref(null);
const selectedProfileId = ref(null);
const selectedSenderId = ref(null);
const deliveryReport = ref(false);
const selectedTemplateId = ref(null);
const sendersForSelectedProfile = computed(() => { const sendersForSelectedProfile = computed(() => {
if (!selectedProfileId.value) return pageSmsSenders.value; if (!form.values.profile_id) return pageSmsSenders.value;
return (pageSmsSenders.value || []).filter( return (pageSmsSenders.value || []).filter(
(s) => s.profile_id === selectedProfileId.value (s) => s.profile_id === form.values.profile_id
); );
}); });
watch(selectedProfileId, () => { const smsEncoding = computed(() =>
if (!selectedSenderId.value) return; isGsm7(form.values.message) ? "GSM-7" : "UCS-2"
const ok = sendersForSelectedProfile.value.some((s) => s.id === selectedSenderId.value); );
if (!ok) selectedSenderId.value = null;
const charCount = computed(() =>
smsEncoding.value === "GSM-7"
? gsm7Length(form.values.message)
: ucs2Length(form.values.message)
);
const perSegment = computed(() => {
const count = charCount.value;
if (smsEncoding.value === "GSM-7") {
return count <= 160 ? 160 : 153;
}
return count <= 70 ? 70 : 67;
}); });
watch(sendersForSelectedProfile, (list) => { const segments = computed(() => {
if (!Array.isArray(list)) return; const count = charCount.value;
if (!selectedSenderId.value && list.length > 0) { const size = perSegment.value || 1;
selectedSenderId.value = list[0].id; return count > 0 ? Math.ceil(count / size) : 0;
});
const creditsNeeded = computed(() => segments.value);
const maxAllowed = computed(() =>
smsEncoding.value === "GSM-7" ? 640 : 320
);
const remaining = computed(() =>
Math.max(0, maxAllowed.value - charCount.value)
);
// Truncate message if exceeds limit
watch(
() => form.values.message,
(val) => {
const limit = maxAllowed.value;
if (charCount.value > limit) {
form.setFieldValue(
"message",
truncateToLimit(val, limit, smsEncoding.value)
);
}
}
);
// Auto-select sender when profile changes
watch(form.values.profile_id, (profileId) => {
if (!profileId) {
form.setFieldValue("sender_id", null);
return;
}
const prof = (pageSmsProfiles.value || []).find((p) => p.id === profileId);
if (prof?.default_sender_id) {
const inList = sendersForSelectedProfile.value.find(
(s) => s.id === prof.default_sender_id
);
if (inList) {
form.setFieldValue("sender_id", prof.default_sender_id);
return;
}
}
// Auto-select first sender if available
if (sendersForSelectedProfile.value.length > 0) {
form.setFieldValue("sender_id", sendersForSelectedProfile.value[0].id);
} else {
form.setFieldValue("sender_id", null);
} }
}); });
// Reset sender if not available for selected profile
watch(sendersForSelectedProfile, (list) => {
if (!form.values.sender_id || !Array.isArray(list)) return;
const ok = list.some((s) => s.id === form.values.sender_id);
if (!ok) form.setFieldValue("sender_id", null);
});
const buildVarsFromSelectedContract = () => { const buildVarsFromSelectedContract = () => {
const uuid = selectedContractUuid.value; const uuid = form.values.contract_uuid;
if (!uuid) return {}; if (!uuid) return {};
const c = (contractsForCase.value || []).find((x) => x.uuid === uuid); const c = (contractsForCase.value || []).find((x) => x.uuid === uuid);
if (!c) return {}; if (!c) return {};
@ -197,10 +296,14 @@ const buildVarsFromSelectedContract = () => {
type: c.account.type, type: c.account.type,
initial_amount: initial_amount:
c.account.initial_amount ?? c.account.initial_amount ??
(c.account.initial_amount_raw ? formatEu(c.account.initial_amount_raw) : null), (c.account.initial_amount_raw
? formatEu(c.account.initial_amount_raw)
: null),
balance_amount: balance_amount:
c.account.balance_amount ?? c.account.balance_amount ??
(c.account.balance_amount_raw ? formatEu(c.account.balance_amount_raw) : null), (c.account.balance_amount_raw
? formatEu(c.account.balance_amount_raw)
: null),
initial_amount_raw: c.account.initial_amount_raw ?? null, initial_amount_raw: c.account.initial_amount_raw ?? null,
balance_amount_raw: c.account.balance_amount_raw ?? null, balance_amount_raw: c.account.balance_amount_raw ?? null,
}; };
@ -209,9 +312,11 @@ const buildVarsFromSelectedContract = () => {
}; };
const updateSmsFromSelection = async () => { const updateSmsFromSelection = async () => {
if (!selectedTemplateId.value) return; if (!form.values.template_id) return;
try { try {
const url = route("clientCase.sms.preview", { client_case: props.clientCaseUuid }); const url = route("clientCase.sms.preview", {
client_case: props.clientCaseUuid,
});
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
@ -222,49 +327,56 @@ const updateSmsFromSelection = async () => {
"", "",
}, },
body: JSON.stringify({ body: JSON.stringify({
template_id: selectedTemplateId.value, template_id: form.values.template_id,
contract_uuid: selectedContractUuid.value || null, contract_uuid: form.values.contract_uuid || null,
}), }),
credentials: "same-origin", credentials: "same-origin",
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (typeof data?.content === "string" && data.content.trim() !== "") { if (typeof data?.content === "string" && data.content.trim() !== "") {
smsMessage.value = data.content; form.setFieldValue("message", data.content);
return; return;
} }
} }
} catch (e) { } catch (e) {
// ignore and fallback // ignore and fallback
} }
// Fallback to client-side template rendering
const tpl = (pageSmsTemplates.value || []).find( const tpl = (pageSmsTemplates.value || []).find(
(t) => t.id === selectedTemplateId.value (t) => t.id === form.values.template_id
); );
if (tpl && typeof tpl.content === "string") { if (tpl && typeof tpl.content === "string") {
smsMessage.value = renderTokens(tpl.content, buildVarsFromSelectedContract()); form.setFieldValue(
"message",
renderTokens(tpl.content, buildVarsFromSelectedContract())
);
} }
}; };
watch(selectedTemplateId, () => { watch(form.values.template_id, () => {
if (!selectedTemplateId.value) return; if (!form.values.template_id) return;
updateSmsFromSelection(); updateSmsFromSelection();
}); });
watch(selectedContractUuid, () => { watch(form.values.contract_uuid, () => {
if (!selectedTemplateId.value) return; if (!form.values.template_id) return;
updateSmsFromSelection(); updateSmsFromSelection();
}); });
watch(pageSmsTemplates, (list) => { watch(pageSmsTemplates, (list) => {
if (!Array.isArray(list)) return; if (!Array.isArray(list)) return;
if (!selectedTemplateId.value && list.length > 0) { if (!form.values.template_id && list.length > 0) {
selectedTemplateId.value = list[0].id; form.setFieldValue("template_id", list[0].id);
} }
}); });
const loadContractsForCase = async () => { const loadContractsForCase = async () => {
try { try {
const url = route("clientCase.contracts.list", { client_case: props.clientCaseUuid }); const url = route("clientCase.contracts.list", {
client_case: props.clientCaseUuid,
});
const res = await fetch(url, { const res = await fetch(url, {
headers: { "X-Requested-With": "XMLHttpRequest" }, headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin", credentials: "same-origin",
@ -280,145 +392,208 @@ watch(
() => props.show, () => props.show,
(newVal) => { (newVal) => {
if (newVal) { if (newVal) {
smsMessage.value = ""; form.resetForm({
selectedProfileId.value = values: {
(pageSmsProfiles.value && pageSmsProfiles.value[0]?.id) || null; message: "",
if (selectedProfileId.value) { template_id: pageSmsTemplates.value?.[0]?.id ?? null,
const prof = (pageSmsProfiles.value || []).find( contract_uuid: null,
(p) => p.id === selectedProfileId.value profile_id: pageSmsProfiles.value?.[0]?.id ?? null,
); sender_id: null,
if (prof && prof.default_sender_id) { delivery_report: false,
},
});
// Set default sender after profile is set
const profileId = pageSmsProfiles.value?.[0]?.id;
if (profileId) {
const prof = (pageSmsProfiles.value || []).find((p) => p.id === profileId);
if (prof?.default_sender_id) {
const inList = sendersForSelectedProfile.value.find( const inList = sendersForSelectedProfile.value.find(
(s) => s.id === prof.default_sender_id (s) => s.id === prof.default_sender_id
); );
selectedSenderId.value = inList ? prof.default_sender_id : null; if (inList) {
} else { form.setFieldValue("sender_id", prof.default_sender_id);
selectedSenderId.value = null; }
} }
} else {
selectedSenderId.value = null;
} }
deliveryReport.value = false;
selectedTemplateId.value =
(pageSmsTemplates.value && pageSmsTemplates.value[0]?.id) || null;
loadContractsForCase(); loadContractsForCase();
} }
} }
); );
const closeSmsDialog = () => { const closeSmsDialog = () => {
emit('close'); emit("close");
}; };
const submitSms = () => { const onSubmit = form.handleSubmit((values) => {
if (!props.phone || !smsMessage.value || !props.clientCaseUuid) { if (!props.phone || !props.clientCaseUuid) return;
return;
} processing.value = true;
smsSending.value = true;
router.post( router.post(
route("clientCase.phone.sms", { route("clientCase.phone.sms", {
client_case: props.clientCaseUuid, client_case: props.clientCaseUuid,
phone_id: props.phone.id, phone_id: props.phone.id,
}), }),
{ values,
message: smsMessage.value,
template_id: selectedTemplateId.value,
contract_uuid: selectedContractUuid.value,
profile_id: selectedProfileId.value,
sender_id: selectedSenderId.value,
delivery_report: !!deliveryReport.value,
},
{ {
preserveScroll: true, preserveScroll: true,
onFinish: () => { onSuccess: () => {
smsSending.value = false; processing.value = false;
closeSmsDialog(); closeSmsDialog();
}, },
onError: () => {
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
} }
); );
}; });
const open = computed({
get: () => props.show,
set: (value) => {
if (!value) closeSmsDialog();
},
});
</script> </script>
<template> <template>
<DialogModal :show="show" @close="closeSmsDialog"> <Dialog v-model:open="open">
<template #title>Pošlji SMS</template> <DialogContent class="sm:max-w-2xl">
<template #content> <DialogHeader>
<div class="space-y-2"> <DialogTitle>Pošlji SMS</DialogTitle>
<p class="text-sm text-gray-600"> <DialogDescription>
Prejemnik: <span class="font-mono">{{ phone?.nu }}</span> <p class="text-sm text-gray-600">
<span v-if="phone?.country_code" class="ml-2 text-xs text-gray-500" Prejemnik: <span class="font-mono">{{ phone?.nu }}</span>
>CC +{{ phone.country_code }}</span <span v-if="phone?.country_code" class="ml-2 text-xs text-gray-500">
> CC +{{ phone.country_code }}
</p> </span>
<!-- Profile & Sender selectors -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<label class="block text-sm font-medium text-gray-700">Profil</label>
<select
v-model="selectedProfileId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="p in pageSmsProfiles" :key="p.id" :value="p.id">
{{ p.name || "Profil #" + p.id }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Pošiljatelj</label>
<select
v-model="selectedSenderId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="s in sendersForSelectedProfile" :key="s.id" :value="s.id">
{{ s.name || s.phone || "Sender #" + s.id }}
</option>
</select>
</div>
</div>
<!-- Contract selector -->
<div>
<label class="block text-sm font-medium text-gray-700">Pogodba</label>
<select
v-model="selectedContractUuid"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="c in contractsForCase" :key="c.uuid" :value="c.uuid">
{{ c.reference || c.uuid }}
</option>
</select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in {account.*}
mest.
</p> </p>
</DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField v-slot="{ value, handleChange }" name="profile_id">
<FormItem>
<FormLabel>Profil</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="p in pageSmsProfiles"
:key="p.id"
:value="p.id"
>
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="sender_id">
<FormItem>
<FormLabel>Pošiljatelj</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="s in sendersForSelectedProfile"
:key="s.id"
:value="s.id"
>
{{ s.name || s.phone || "Sender #" + s.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div> </div>
<!-- Template selector --> <FormField v-slot="{ value, handleChange }" name="contract_uuid">
<div> <FormItem>
<label class="block text-sm font-medium text-gray-700">Predloga</label> <FormLabel>Pogodba</FormLabel>
<select <Select :model-value="value" @update:model-value="handleChange">
v-model="selectedTemplateId" <FormControl>
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" <SelectTrigger>
> <SelectValue placeholder="—" />
<option :value="null"></option> </SelectTrigger>
<option v-for="t in pageSmsTemplates" :key="t.id" :value="t.id"> </FormControl>
{{ t.name || "Predloga #" + t.id }} <SelectContent>
</option> <SelectItem :value="null"></SelectItem>
</select> <SelectItem
</div> v-for="c in contractsForCase"
:key="c.uuid"
:value="c.uuid"
>
{{ c.reference || c.uuid }}
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in
{account.*} mest.
</p>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="template_id">
<FormItem>
<FormLabel>Predloga</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="t in pageSmsTemplates"
:key="t.id"
:value="t.id"
>
{{ t.name || "Predloga #" + t.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>Vsebina sporočila</FormLabel>
<FormControl>
<Textarea
rows="4"
placeholder="Vpišite SMS vsebino..."
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<label class="block text-sm font-medium text-gray-700">Vsebina sporočila</label>
<textarea
v-model="smsMessage"
rows="4"
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Vpišite SMS vsebino..."
></textarea>
<!-- Live counters --> <!-- Live counters -->
<div class="mt-1 text-xs text-gray-600 flex flex-col gap-1"> <div class="text-xs text-gray-600 flex flex-col gap-1">
<div> <div>
<span class="font-medium">Znakov:</span> <span class="font-medium">Znakov:</span>
<span class="font-mono">{{ charCount }}</span> <span class="font-mono">{{ charCount }}</span>
@ -437,44 +612,49 @@ const submitSms = () => {
<span class="font-mono">{{ maxAllowed }}</span> <span class="font-mono">{{ maxAllowed }}</span>
<span class="mx-2">|</span> <span class="mx-2">|</span>
<span class="font-medium">Preostanek:</span> <span class="font-medium">Preostanek:</span>
<span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">{{ <span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">
remaining {{ remaining }}
}}</span> </span>
</div> </div>
<p class="text-[11px] text-gray-500 leading-snug"> <p class="text-[11px] text-gray-500 leading-snug">
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake, ki Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo
ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del znake, ki ne zahtevajo enkodiranja. Če npr. želite pošiljati
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS2). V tem šumnike, ki niso del 7-bitne abecede GSM, morate uporabiti Unicode
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših enkodiranje (UCS2). V tem primeru je največja dolžina enega SMS
sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših sporočila 70 znakov (pri daljših sporočilih 67 znakov na del),
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in ) medtem ko je pri GSM7 160 znakov (pri daljših sporočilih 153
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM7) oziroma znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in ) štejejo
dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM7) oziroma
320 (UCS2) znakov. 320 (UCS2) znakov.
</p> </p>
</div> </div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mt-1"> <FormField v-slot="{ value, handleChange }" name="delivery_report">
<input <FormItem class="flex flex-row items-start space-x-3 space-y-0">
type="checkbox" <FormControl>
v-model="deliveryReport" <Switch
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" :model-value="value"
/> @update:model-value="handleChange"
Zahtevaj poročilo o dostavi />
</label> </FormControl>
</div> <div class="space-y-1 leading-none">
</template> <FormLabel>Zahtevaj poročilo o dostavi</FormLabel>
<template #footer> </div>
<button class="px-3 py-1 rounded border mr-2" @click="closeSmsDialog"> </FormItem>
Prekliči </FormField>
</button> </form>
<button
class="px-3 py-1 rounded bg-indigo-600 text-white disabled:opacity-50"
:disabled="smsSending || !smsMessage"
@click="submitSms"
>
Pošlji
</button>
</template>
</DialogModal>
</template>
<DialogFooter>
<Button variant="outline" @click="closeSmsDialog" :disabled="processing">
Prekliči
</Button>
<Button
@click="onSubmit"
:disabled="processing || !form.values.message"
>
Pošlji
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons"; import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue"; import Dropdown from "../Dropdown.vue";
const props = defineProps({ const props = defineProps({
@ -23,17 +23,7 @@ const handleDelete = (id, label) => emit('delete', id, label);
</script> </script>
<template> <template>
<div class="flex justify-end mb-3" v-if="edit"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<button
@click="handleAdd"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
title="Dodaj TRR"
>
<PlusIcon size="sm" />
<span>Dodaj TRR</span>
</button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<template v-if="getTRRs(person).length"> <template v-if="getTRRs(person).length">
<div <div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow" class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
@ -108,7 +98,15 @@ const handleDelete = (id, label) => emit('delete', id, label);
</p> </p>
</div> </div>
</template> </template>
<p v-else class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200"> <button
v-if="edit"
@click="handleAdd"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
title="Dodaj TRR"
>
<PlusIcon class="h-8 w-8 text-gray-400" />
</button>
<p v-else-if="!edit && !getTRRs(person).length" class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
Ni TRR računov. Ni TRR računov.
</p> </p>
</div> </div>

View File

@ -20,7 +20,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/Components/ui/select"; } from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox"; import { Switch } from "@/Components/ui/switch";
const props = defineProps({ const props = defineProps({
show: { show: {
@ -216,7 +216,7 @@ const onSubmit = form.handleSubmit(() => {
<FormField v-slot="{ value, handleChange }" name="validated"> <FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0"> <FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
<Checkbox :checked="value" @update:checked="handleChange" /> <Switch :model-value="value" @update:model-value="handleChange" />
</FormControl> </FormControl>
<div class="space-y-1 leading-none"> <div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Potrjeno</FormLabel> <FormLabel class="cursor-pointer">Potrjeno</FormLabel>

View File

@ -20,7 +20,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/Components/ui/select"; } from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox"; import { Switch } from "@/Components/ui/switch";
const props = defineProps({ const props = defineProps({
show: { show: {
@ -241,7 +241,7 @@ const onSubmit = form.handleSubmit(() => {
<FormField v-slot="{ value, handleChange }" name="validated"> <FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0"> <FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
<Checkbox :checked="value" @update:checked="handleChange" /> <Switch :model-value="value" @update:model-value="handleChange" />
</FormControl> </FormControl>
<div class="space-y-1 leading-none"> <div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Potrjeno</FormLabel> <FormLabel class="cursor-pointer">Potrjeno</FormLabel>

View File

@ -15,7 +15,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/Components/ui/select"; } from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox"; import { Switch } from "@/Components/ui/switch";
import { ref, watch, computed } from "vue"; import { ref, watch, computed } from "vue";
const props = defineProps({ const props = defineProps({
@ -357,7 +357,7 @@ watch(
<div v-if="showSendAutoMail()" class="space-y-2"> <div v-if="showSendAutoMail()" class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Checkbox <Switch
v-model="form.send_auto_mail" v-model="form.send_auto_mail"
:disabled="autoMailDisabled" :disabled="autoMailDisabled"
/> />
@ -370,7 +370,7 @@ watch(
<div v-if="templateAllowsAttachments && form.contract_uuid" class="mt-3"> <div v-if="templateAllowsAttachments && form.contract_uuid" class="mt-3">
<label class="inline-flex items-center gap-2"> <label class="inline-flex items-center gap-2">
<Checkbox v-model="form.attach_documents" /> <Switch v-model="form.attach_documents" />
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span> <span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
</label> </label>
<div <div
@ -392,9 +392,9 @@ watch(
:key="doc.uuid || doc.id" :key="doc.uuid || doc.id"
class="flex items-center gap-2 text-sm" class="flex items-center gap-2 text-sm"
> >
<Checkbox <Switch
:checked="form.attachment_document_ids.includes(doc.id)" :model-value="form.attachment_document_ids.includes(doc.id)"
@update:checked="(checked) => { @update:model-value="(checked) => {
if (checked) { if (checked) {
if (!form.attachment_document_ids.includes(doc.id)) { if (!form.attachment_document_ids.includes(doc.id)) {
form.attachment_document_ids.push(doc.id); form.attachment_document_ids.push(doc.id);

View File

@ -1,12 +1,12 @@
<script setup> <script setup>
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { Link, router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import DataTable from "@/Components/DataTable/DataTable.vue"; import DataTable from "@/Components/DataTable/DataTable.vue";
import Dropdown from "@/Components/Dropdown.vue";
import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue"; import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core"; import { library } from "@fortawesome/fontawesome-svg-core";
import { faTrash, faEllipsisVertical, faCopy } from "@fortawesome/free-solid-svg-icons"; import { faTrash, faEllipsisVertical, faCopy } from "@fortawesome/free-solid-svg-icons";
import Dropdown from "@/Components/Dropdown.vue";
library.add(faTrash, faEllipsisVertical, faCopy); library.add(faTrash, faEllipsisVertical, faCopy);
@ -35,6 +35,7 @@ const fmtDate = (d) => {
return String(d); return String(d);
} }
}; };
const fmtDateTime = (d) => { const fmtDateTime = (d) => {
if (!d) return ""; if (!d) return "";
try { try {
@ -54,12 +55,11 @@ const fmtDateTime = (d) => {
return String(d); return String(d);
} }
}; };
const fmtCurrency = (v) => { const fmtCurrency = (v) => {
const n = Number(v ?? 0); const n = Number(v ?? 0);
try { try {
return new Intl.NumberFormat("sl-SI", { style: "currency", currency: "EUR" }).format( return new Intl.NumberFormat("sl-SI", { style: "currency", currency: "EUR" }).format(n);
n
);
} catch { } catch {
return `${n.toFixed(2)}`; return `${n.toFixed(2)}`;
} }
@ -72,36 +72,32 @@ const deleteActivity = (row) => {
client_case: props.client_case.uuid, client_case: props.client_case.uuid,
activity: row.id, activity: row.id,
}), }),
{ { preserveScroll: true }
preserveScroll: true,
}
); );
}; };
// Confirmation modal state
const confirmDelete = ref(false); const confirmDelete = ref(false);
const toDeleteRow = ref(null); const toDeleteRow = ref(null);
const openDelete = (row) => { const openDelete = (row) => {
toDeleteRow.value = row; toDeleteRow.value = row;
confirmDelete.value = true; confirmDelete.value = true;
}; };
const cancelDelete = () => { const cancelDelete = () => {
confirmDelete.value = false; confirmDelete.value = false;
toDeleteRow.value = null; toDeleteRow.value = null;
}; };
const confirmDeleteAction = () => { const confirmDeleteAction = () => {
if (toDeleteRow.value) deleteActivity(toDeleteRow.value); if (toDeleteRow.value) deleteActivity(toDeleteRow.value);
confirmDelete.value = false; cancelDelete();
toDeleteRow.value = null;
}; };
// Copy function
const copyToClipboard = async (text) => { const copyToClipboard = async (text) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
// You could add a toast notification here if available
} catch (err) { } catch (err) {
// Fallback for older browsers
const textArea = document.createElement("textarea"); const textArea = document.createElement("textarea");
textArea.value = text; textArea.value = text;
document.body.appendChild(textArea); document.body.appendChild(textArea);
@ -113,23 +109,24 @@ const copyToClipboard = async (text) => {
</script> </script>
<template> <template>
<div class="relative"> <div class="p-4">
<div class="activity-scroll-wrapper max-h-[32rem] overflow-y-auto overflow-x-auto"> <DataTable
<DataTable :columns="columns"
:columns="columns" :rows="rows"
:rows="rows" :show-toolbar="true"
:show-toolbar="true" :show-pagination="false"
:show-pagination="false" :show-search="false"
:show-search="false" :show-page-size="false"
:show-page-size="false" :show-add="!!$slots.add"
:hoverable="true" :hoverable="true"
row-key="id" row-key="id"
empty-text="Ni aktivnosti." empty-text="Ni aktivnosti."
class="border-0" class="border-0"
> >
<template #toolbar-add> <template #toolbar-add>
<slot name="add" /> <slot name="add" />
</template> </template>
<template #cell-decision_dot="{ row }"> <template #cell-decision_dot="{ row }">
<div class="flex justify-center"> <div class="flex justify-center">
<span <span
@ -137,18 +134,13 @@ const copyToClipboard = async (text) => {
class="inline-block h-4 w-4 rounded-full ring-1 ring-gray-300" class="inline-block h-4 w-4 rounded-full ring-1 ring-gray-300"
:style="{ backgroundColor: row.decision?.color_tag }" :style="{ backgroundColor: row.decision?.color_tag }"
:title="row.decision?.color_tag" :title="row.decision?.color_tag"
aria-hidden="true" />
></span>
</div> </div>
</template> </template>
<template #cell-contract="{ row }"> <template #cell-contract="{ row }">
<template v-if="row.contract?.reference"> <span v-if="row.contract?.reference">{{ row.contract.reference }}</span>
{{ row.contract.reference }} <span v-else class="text-gray-400"></span>
</template>
<template v-else>
<span class="text-gray-400"></span>
</template>
</template> </template>
<template #cell-decision="{ row }"> <template #cell-decision="{ row }">
@ -170,24 +162,15 @@ const copyToClipboard = async (text) => {
</template> </template>
<template v-else-if="row.note"> <template v-else-if="row.note">
<span>{{ row.note.slice(0, 60) }} </span> <span>{{ row.note.slice(0, 60) }} </span>
<Dropdown <Dropdown align="left" width="56" :content-classes="['p-2', 'bg-white', 'shadow', 'max-w-xs']">
align="left"
width="56"
:content-classes="['p-2', 'bg-white', 'shadow', 'max-w-xs']"
>
<template #trigger> <template #trigger>
<button <button type="button" class="inline-flex items-center text-[11px] text-indigo-600 hover:underline">
type="button"
class="inline-flex items-center text-[11px] text-indigo-600 hover:underline focus:outline-none"
>
Več Več
</button> </button>
</template> </template>
<template #content> <template #content>
<div class="relative" @click.stop> <div class="relative" @click.stop>
<div <div class="flex items-center justify-between p-1 border-b border-gray-200">
class="flex items-center justify-between p-1 border-b border-gray-200"
>
<span class="text-xs font-medium text-gray-600">Opomba</span> <span class="text-xs font-medium text-gray-600">Opomba</span>
<button <button
@click="copyToClipboard(row.note)" @click="copyToClipboard(row.note)"
@ -198,9 +181,7 @@ const copyToClipboard = async (text) => {
<span>Kopiraj</span> <span>Kopiraj</span>
</button> </button>
</div> </div>
<div <div class="max-h-60 overflow-auto text-[12px] whitespace-pre-wrap break-words p-2">
class="max-h-60 overflow-auto text-[12px] whitespace-pre-wrap break-words p-2"
>
{{ row.note }} {{ row.note }}
</div> </div>
</div> </div>
@ -223,10 +204,7 @@ const copyToClipboard = async (text) => {
<span class="text-gray-500">D:</span> <span class="text-gray-500">D:</span>
<span class="ml-1">{{ fmtDate(row.due_date) }}</span> <span class="ml-1">{{ fmtDate(row.due_date) }}</span>
</div> </div>
<div <div v-if="!row.due_date && (!row.amount || Number(row.amount) === 0)" class="text-gray-400">
v-if="!row.due_date && (!row.amount || Number(row.amount) === 0)"
class="text-gray-400"
>
</div> </div>
</div> </div>
@ -237,9 +215,7 @@ const copyToClipboard = async (text) => {
{{ row.user?.name || row.user_name || "" }} {{ row.user?.name || row.user_name || "" }}
</div> </div>
<div v-if="row.created_at" class="mt-1"> <div v-if="row.created_at" class="mt-1">
<span <span class="inline-block px-2 py-0.5 rounded text-[11px] bg-gray-100 text-gray-600 tracking-wide">
class="inline-block px-2 py-0.5 rounded text-[11px] bg-gray-100 text-gray-600 tracking-wide"
>
{{ fmtDateTime(row.created_at) }} {{ fmtDateTime(row.created_at) }}
</span> </span>
</div> </div>
@ -251,12 +227,9 @@ const copyToClipboard = async (text) => {
<button <button
type="button" type="button"
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none" class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
title="Actions" title="Možnosti"
> >
<FontAwesomeIcon <FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4 text-gray-700" />
:icon="faEllipsisVertical"
class="h-4 w-4 text-gray-700"
/>
</button> </button>
</template> </template>
<template #content> <template #content>
@ -271,8 +244,7 @@ const copyToClipboard = async (text) => {
</template> </template>
</Dropdown> </Dropdown>
</template> </template>
</DataTable> </DataTable>
</div>
</div> </div>
<DeleteDialog <DeleteDialog
@ -285,42 +257,3 @@ const copyToClipboard = async (text) => {
/> />
</template> </template>
<style scoped>
.activity-scroll-wrapper {
scrollbar-gutter: stable;
}
/* Ensure sticky header works within scroll container */
.activity-scroll-wrapper :deep(table) {
border-collapse: separate;
border-spacing: 0;
}
.activity-scroll-wrapper :deep([data-table-container]) {
overflow: visible !important;
}
.activity-scroll-wrapper :deep([data-table-container] > div) {
overflow-x: visible !important;
overflow-y: visible !important;
}
.activity-scroll-wrapper :deep(table thead) {
position: sticky;
top: 0;
z-index: 20;
}
.activity-scroll-wrapper :deep(table thead tr) {
background-color: white;
}
.activity-scroll-wrapper :deep(table thead th) {
background-color: white !important;
position: sticky;
top: 0;
z-index: 20;
box-shadow: 0 1px 0 0 #e5e7eb;
border-bottom: 1px solid #e5e7eb;
}
</style>

View File

@ -1,13 +1,20 @@
<script setup> <script setup>
import ActionMessage from "@/Components/ActionMessage.vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue"; import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue"; import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
import SectionTitle from "@/Components/SectionTitle.vue"; import SectionTitle from "@/Components/SectionTitle.vue";
import CurrencyInput from "@/Components/CurrencyInput.vue"; import CurrencyInput from "@/Components/CurrencyInput.vue";
import DatePicker from "@/Components/DatePicker.vue"; import DatePicker from "@/Components/DatePicker.vue";
import { useForm, router } from "@inertiajs/vue3"; import { useForm, Field as FormField } from "vee-validate";
import { watch, nextTick, ref as vRef } from "vue"; import { toTypedSchema } from "@vee-validate/zod";
import { Label } from "@/Components/ui/label"; import * as z from "zod";
import { router } from "@inertiajs/vue3";
import { watch, ref } from "vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea"; import { Textarea } from "@/Components/ui/textarea";
import { import {
@ -23,367 +30,428 @@ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
types: Array, types: Array,
account_types: { type: Array, default: () => [] }, account_types: { type: Array, default: () => [] },
// Optional: when provided, drawer acts as edit mode
contract: { type: Object, default: null }, contract: { type: Object, default: null },
}); });
const emit = defineEmits(["close"]); const emit = defineEmits(["close"]);
const close = () => { const formSchema = toTypedSchema(
// Clear any previous validation warnings when closing z.object({
formContract.clearErrors(); client_case_uuid: z.string(),
formContract.recentlySuccessful = false; uuid: z.string().nullable().optional(),
emit("close"); reference: z.string().min(1, "Referenca je obvezna."),
}; start_date: z.string().optional(),
type_id: z.number().optional(),
description: z.string().optional(),
initial_amount: z.number().nullable().optional(),
balance_amount: z.number().nullable().optional(),
account_type_id: z.number().nullable().optional(),
})
);
// form state for create or edit const form = useForm({
const formContract = useForm({ validationSchema: formSchema,
client_case_uuid: props.client_case.uuid, initialValues: {
uuid: props.contract?.uuid ?? null, client_case_uuid: props.client_case.uuid,
reference: props.contract?.reference ?? "", uuid: props.contract?.uuid ?? null,
start_date: props.contract?.start_date ?? new Date().toISOString(), reference: props.contract?.reference ?? "",
type_id: props.contract?.type_id ?? props.contract?.type?.id ?? props.types[0].id, start_date: props.contract?.start_date ?? new Date().toISOString().split("T")[0],
description: props.contract?.description ?? "", type_id: props.contract?.type_id ?? props.contract?.type?.id ?? (props.types?.[0]?.id ?? null),
// nested account fields, if exists description: props.contract?.description ?? "",
initial_amount: props.contract?.account?.initial_amount ?? null, initial_amount: props.contract?.account?.initial_amount ?? null,
balance_amount: props.contract?.account?.balance_amount ?? null, balance_amount: props.contract?.account?.balance_amount ?? null,
account_type_id: props.contract?.account?.type_id ?? null, account_type_id: props.contract?.account?.type_id ?? null,
},
}); });
// keep form in sync when switching between create and edit const processing = ref(false);
const applyContract = (c) => {
formContract.uuid = c?.uuid ?? null; const close = () => {
formContract.reference = c?.reference ?? ""; emit("close");
formContract.start_date = c?.start_date ?? new Date().toISOString(); setTimeout(() => {
formContract.type_id = c?.type_id ?? c?.type?.id ?? props.types[0].id; form.resetForm();
formContract.description = c?.description ?? ""; processing.value = false;
formContract.initial_amount = c?.account?.initial_amount ?? null; }, 300);
formContract.balance_amount = c?.account?.balance_amount ?? null;
formContract.account_type_id = c?.account?.type_id ?? null;
}; };
watch( watch(
() => props.contract, () => [props.show, props.contract],
(c) => { () => {
applyContract(c); if (props.show && props.contract) {
} // Edit mode - set values from contract
); form.setValues({
client_case_uuid: props.client_case.uuid,
watch( uuid: props.contract.uuid ?? null,
() => props.show, reference: props.contract.reference ?? "",
(open) => { start_date: props.contract.start_date ?? new Date().toISOString().split("T")[0],
if (open && !props.contract) { type_id: props.contract.type_id ?? props.contract.type?.id ?? (props.types?.[0]?.id ?? null),
// reset for create description: props.contract.description ?? "",
applyContract(null); initial_amount: props.contract.account?.initial_amount ?? null,
balance_amount: props.contract.account?.balance_amount ?? null,
account_type_id: props.contract.account?.type_id ?? null,
});
} else if (props.show && !props.contract) {
// Create mode - reset to defaults
form.resetForm({
values: {
client_case_uuid: props.client_case.uuid,
uuid: null,
reference: "",
start_date: new Date().toISOString().split("T")[0],
type_id: props.types?.[0]?.id ?? null,
description: "",
initial_amount: null,
balance_amount: null,
account_type_id: null,
},
});
} }
if (!open) { },
// Ensure warnings are cleared when dialog hides { immediate: true }
formContract.clearErrors();
formContract.recentlySuccessful = false;
}
}
);
// optional: focus the reference input when a reference validation error appears
const contractRefInput = vRef(null);
watch(
() => formContract.errors.reference,
async (err) => {
if (err && props.show) {
await nextTick();
try {
contractRefInput.value?.focus?.();
} catch (e) {}
}
}
); );
const storeOrUpdate = () => { const storeOrUpdate = () => {
const isEdit = !!formContract.uuid; const { values } = form;
// Debug: log payload being sent to verify balance_amount presence const isEdit = !!values.uuid;
try {
console.debug('Submitting contract form', JSON.parse(JSON.stringify(formContract))); processing.value = true;
} catch (e) {}
const options = { const options = {
onBefore: () => { preserveScroll: true,
formContract.start_date = formContract.start_date; preserveState: true,
}, only: [],
onSuccess: () => { onSuccess: () => {
close(); close();
// keep state clean; reset to initial
if (!isEdit) formContract.reset();
// After edit ensure contracts list reflects updated balance
if (isEdit) {
try {
const params = {};
try {
const url = new URL(window.location.href);
const seg = url.searchParams.get('segment');
if (seg) params.segment = seg;
} catch (e) {}
router.visit(route('clientCase.show', { client_case: props.client_case.uuid, ...params }), {
preserveScroll: true,
replace: true,
});
} catch (e) {}
}
}, },
preserveScroll: true, onError: (errors) => {
// Map Inertia errors to VeeValidate field errors
Object.keys(errors).forEach((field) => {
const message = Array.isArray(errors[field])
? errors[field][0]
: errors[field];
form.setFieldError(field, message);
});
},
onFinish: () => {
processing.value = false;
},
}; };
const params = {};
try {
const url = new URL(window.location.href);
const seg = url.searchParams.get("segment");
if (seg) params.segment = seg;
} catch (e) {}
if (isEdit) { if (isEdit) {
formContract.put( router.put(
route("clientCase.contract.update", { route("clientCase.contract.update", {
client_case: props.client_case.uuid, client_case: props.client_case.uuid,
uuid: formContract.uuid, uuid: values.uuid,
...params,
}), }),
values,
options options
); );
} else { } else {
// route helper merges params for GET; for POST we can append query manually if needed router.post(
let postUrl = route("clientCase.contract.store", props.client_case); route("clientCase.contract.store", props.client_case),
if (params.segment) { values,
postUrl += options
(postUrl.includes("?") ? "&" : "?") + );
"segment=" +
encodeURIComponent(params.segment);
}
formContract.post(postUrl, options);
} }
}; };
const onSubmit = form.handleSubmit(() => {
storeOrUpdate();
});
const onConfirm = () => {
onSubmit();
};
</script> </script>
<template> <template>
<CreateDialog <CreateDialog
v-if="!formContract.uuid" v-if="!form.values.uuid"
:show="show" :show="show"
title="Dodaj pogodbo" title="Dodaj pogodbo"
confirm-text="Shrani" confirm-text="Shrani"
:processing="formContract.processing" :processing="processing"
@close="close" @close="close"
@confirm="storeOrUpdate" @confirm="onConfirm"
> >
<form @submit.prevent="storeOrUpdate"> <form @submit.prevent="onSubmit" class="space-y-4">
<div <SectionTitle class="mt-4 border-b mb-4">
v-if="formContract.errors.reference" <template #title> Pogodba </template>
class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" </SectionTitle>
>
{{ formContract.errors.reference }}
</div>
<SectionTitle class="mt-4 border-b mb-4">
<template #title> Pogodba </template>
</SectionTitle>
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <FormField v-slot="{ componentField }" name="reference">
<Label for="contractRef">Referenca</Label> <FormItem>
<Input <FormLabel>Referenca</FormLabel>
id="contractRef" <FormControl>
ref="contractRefInput" <Input
v-model="formContract.reference" id="contractRef"
type="text" type="text"
:class="[ placeholder="Referenca"
formContract.errors.reference autocomplete="contract-reference"
? 'border-red-500 focus:border-red-500 focus:ring-red-500' v-bind="componentField"
: '', />
]" </FormControl>
autocomplete="contract-reference" <FormMessage />
/> </FormItem>
<p v-if="formContract.errors.reference" class="text-sm text-red-600"> </FormField>
{{ formContract.errors.reference }}
</p>
</div>
<div class="space-y-2"> <FormField v-slot="{ value, handleChange }" name="start_date">
<Label for="contractStartDate">Datum pričetka</Label> <FormItem>
<DatePicker <FormLabel>Datum pričetka</FormLabel>
id="contractStartDate" <FormControl>
v-model="formContract.start_date" <DatePicker
format="dd.MM.yyyy" id="contractStartDate"
:error="formContract.errors.start_date" :model-value="value"
/> @update:model-value="handleChange"
</div> format="dd.MM.yyyy"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="space-y-2"> <FormField v-slot="{ value, handleChange }" name="type_id">
<Label for="contractTypeSelect">Tip</Label> <FormItem>
<Select v-model="formContract.type_id"> <FormLabel>Tip</FormLabel>
<SelectTrigger> <Select :model-value="value" @update:model-value="handleChange">
<SelectValue placeholder="Izberi tip" /> <FormControl>
</SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Izberi tip" />
</SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="t in types" :key="t.id" :value="t.id"> <SelectItem
v-for="t in types"
:key="t.id"
:value="t.id"
>
{{ t.name }} {{ t.name }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> <FormMessage />
</FormItem>
</FormField>
<div class="space-y-2"> <FormField v-slot="{ componentField }" name="description">
<Label for="contractDescription">Opis</Label> <FormItem>
<Textarea <FormLabel>Opis</FormLabel>
id="contractDescription" <FormControl>
v-model="formContract.description" <Textarea
rows="3" id="contractDescription"
placeholder="Opis" rows="3"
/> placeholder="Opis"
</div> v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<SectionTitle class="mt-6 border-b mb-4"> <SectionTitle class="mt-6 border-b mb-4">
<template #title> Račun </template> <template #title> Račun </template>
</SectionTitle> </SectionTitle>
<div class="space-y-2"> <FormField v-slot="{ value, handleChange }" name="account_type_id">
<Label for="accountTypeSelect">Tip računa</Label> <FormItem>
<Select v-model="formContract.account_type_id"> <FormLabel>Tip računa</FormLabel>
<SelectTrigger> <Select :model-value="value" @update:model-value="handleChange">
<SelectValue placeholder="Izberi tip računa" /> <FormControl>
</SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Izberi tip računa" />
</SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
<SelectItem :value="null"></SelectItem> <SelectItem :value="null"></SelectItem>
<SelectItem v-for="at in account_types" :key="at.id" :value="at.id"> <SelectItem
v-for="at in account_types"
:key="at.id"
:value="at.id"
>
{{ at.name ?? "#" + at.id }} {{ at.name ?? "#" + at.id }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p v-if="formContract.errors.account_type_id" class="text-sm text-red-600"> <FormMessage />
{{ formContract.errors.account_type_id }} </FormItem>
</p> </FormField>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2"> <FormField v-slot="{ value, handleChange }" name="initial_amount">
<Label for="initialAmount">Predani znesek</Label> <FormItem>
<CurrencyInput id="initialAmount" v-model="formContract.initial_amount" /> <FormLabel>Predani znesek</FormLabel>
</div> <FormControl>
<div class="space-y-2"> <CurrencyInput
<Label for="balanceAmount">Odprti znesek</Label> id="initialAmount"
<CurrencyInput id="balanceAmount" v-model="formContract.balance_amount" /> :model-value="value"
</div> @update:model-value="handleChange"
</div> />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<ActionMessage :on="formContract.recentlySuccessful" class="text-sm text-green-600"> <FormField v-slot="{ value, handleChange }" name="balance_amount">
Shranjuje. <FormItem>
</ActionMessage> <FormLabel>Odprti znesek</FormLabel>
<FormControl>
<CurrencyInput
id="balanceAmount"
:model-value="value"
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div> </div>
</form> </div>
</CreateDialog> </form>
<UpdateDialog </CreateDialog>
v-else
:show="show"
title="Uredi pogodbo"
confirm-text="Posodobi"
:processing="formContract.processing"
@close="close"
@confirm="storeOrUpdate"
>
<form @submit.prevent="storeOrUpdate">
<div
v-if="formContract.errors.reference"
class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
>
{{ formContract.errors.reference }}
</div>
<SectionTitle class="mt-4 border-b mb-4">
<template #title> Pogodba </template>
</SectionTitle>
<div class="space-y-4"> <UpdateDialog
<div class="space-y-2"> v-else
<Label for="contractRef">Referenca</Label> :show="show"
<Input title="Uredi pogodbo"
id="contractRef" confirm-text="Posodobi"
ref="contractRefInput" :processing="processing"
v-model="formContract.reference" @close="close"
type="text" @confirm="onConfirm"
:class="[ >
formContract.errors.reference <form @submit.prevent="onSubmit" class="space-y-4">
? 'border-red-500 focus:border-red-500 focus:ring-red-500' <SectionTitle class="mt-4 border-b mb-4">
: '', <template #title> Pogodba </template>
]" </SectionTitle>
autocomplete="contract-reference"
/>
<p v-if="formContract.errors.reference" class="text-sm text-red-600">
{{ formContract.errors.reference }}
</p>
</div>
<div class="space-y-2"> <div class="space-y-4">
<Label for="contractStartDate">Datum pričetka</Label> <FormField v-slot="{ componentField }" name="reference">
<DatePicker <FormItem>
id="contractStartDate" <FormLabel>Referenca</FormLabel>
v-model="formContract.start_date" <FormControl>
format="dd.MM.yyyy" <Input
:error="formContract.errors.start_date" id="contractRef"
/> type="text"
</div> placeholder="Referenca"
autocomplete="contract-reference"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="space-y-2"> <FormField v-slot="{ value, handleChange }" name="start_date">
<Label for="contractTypeSelect">Tip</Label> <FormItem>
<Select v-model="formContract.type_id"> <FormLabel>Datum pričetka</FormLabel>
<SelectTrigger> <FormControl>
<SelectValue placeholder="Izberi tip" /> <DatePicker
</SelectTrigger> id="contractStartDate"
:model-value="value"
@update:model-value="handleChange"
format="dd.MM.yyyy"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="type_id">
<FormItem>
<FormLabel>Tip</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi tip" />
</SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="t in types" :key="t.id" :value="t.id"> <SelectItem
v-for="t in types"
:key="t.id"
:value="t.id"
>
{{ t.name }} {{ t.name }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> <FormMessage />
</FormItem>
</FormField>
<div class="space-y-2"> <FormField v-slot="{ componentField }" name="description">
<Label for="contractDescription">Opis</Label> <FormItem>
<Textarea <FormLabel>Opis</FormLabel>
id="contractDescription" <FormControl>
v-model="formContract.description" <Textarea
rows="3" id="contractDescription"
placeholder="Opis" rows="3"
/> placeholder="Opis"
</div> v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<SectionTitle class="mt-6 border-b mb-4"> <SectionTitle class="mt-6 border-b mb-4">
<template #title> Račun </template> <template #title> Račun </template>
</SectionTitle> </SectionTitle>
<div class="space-y-2"> <FormField v-slot="{ value, handleChange }" name="account_type_id">
<Label for="accountTypeSelect">Tip računa</Label> <FormItem>
<Select v-model="formContract.account_type_id"> <FormLabel>Tip računa</FormLabel>
<SelectTrigger> <Select :model-value="value" @update:model-value="handleChange">
<SelectValue placeholder="Izberi tip računa" /> <FormControl>
</SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Izberi tip računa" />
</SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
<SelectItem :value="null"></SelectItem> <SelectItem :value="null"></SelectItem>
<SelectItem v-for="at in account_types" :key="at.id" :value="at.id"> <SelectItem
v-for="at in account_types"
:key="at.id"
:value="at.id"
>
{{ at.name ?? "#" + at.id }} {{ at.name ?? "#" + at.id }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p v-if="formContract.errors.account_type_id" class="text-sm text-red-600"> <FormMessage />
{{ formContract.errors.account_type_id }} </FormItem>
</p> </FormField>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2"> <FormField v-slot="{ value, handleChange }" name="initial_amount">
<Label for="initialAmount">Predani znesek</Label> <FormItem>
<CurrencyInput id="initialAmount" v-model="formContract.initial_amount" /> <FormLabel>Predani znesek</FormLabel>
</div> <FormControl>
<div class="space-y-2"> <CurrencyInput
<Label for="balanceAmount">Odprti znesek</Label> id="initialAmount"
<CurrencyInput id="balanceAmount" v-model="formContract.balance_amount" /> :model-value="value"
</div> @update:model-value="handleChange"
</div> />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<ActionMessage :on="formContract.recentlySuccessful" class="text-sm text-green-600"> <FormField v-slot="{ value, handleChange }" name="balance_amount">
Shranjuje. <FormItem>
</ActionMessage> <FormLabel>Odprti znesek</FormLabel>
<FormControl>
<CurrencyInput
id="balanceAmount"
:model-value="value"
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div> </div>
</form> </div>
</UpdateDialog> </form>
</UpdateDialog>
</template> </template>