Dev branch

This commit is contained in:
Simon Pocrnjič
2025-11-02 12:31:01 +01:00
parent 5f879c9436
commit 63e0958b66
241 changed files with 17686 additions and 7327 deletions
@@ -0,0 +1,283 @@
<script setup>
import { ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
person: Object,
types: Array,
edit: {
type: Boolean,
default: false,
},
id: {
type: Number,
default: 0,
},
});
const formSchema = toTypedSchema(
z.object({
address: z.string().min(1, "Naslov je obvezen."),
country: z.string().optional(),
post_code: z.string().optional(),
city: z.string().optional(),
type_id: z.number().nullable(),
description: z.string().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
address: "",
country: "",
post_code: "",
city: "",
type_id: props.types?.[0]?.id ?? null,
description: "",
},
});
const processing = ref(false);
const emit = defineEmits(["close"]);
const close = () => {
emit("close");
setTimeout(() => {
form.resetForm();
processing.value = false;
}, 300);
};
const resetForm = () => {
form.resetForm({
values: {
address: "",
country: "",
post_code: "",
city: "",
type_id: props.types?.[0]?.id ?? null,
description: "",
},
});
};
watch(
() => props.id,
(id) => {
if (props.edit && id !== 0) {
const a = props.person.addresses?.find((x) => x.id === id);
if (a) {
form.setValues({
address: a.address || "",
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
description: a.description || "",
});
return;
}
}
resetForm();
},
{ immediate: true }
);
watch(() => props.show, (val) => {
if (val && props.edit && props.id) {
const a = props.person.addresses?.find((x) => x.id === props.id);
if (a) {
form.setValues({
address: a.address || "",
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
description: a.description || "",
});
}
} else if (val && !props.edit) {
resetForm();
}
});
const create = async () => {
processing.value = true;
const { values } = form;
router.post(
route("person.address.create", props.person),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const update = async () => {
processing.value = true;
const { values } = form;
router.put(
route("person.address.update", { person: props.person, address_id: props.id }),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const callSubmit = () => {
if (props.edit) {
update();
} else {
create();
}
};
const onSubmit = form.handleSubmit(() => {
callSubmit();
});
const onConfirm = () => {
onSubmit();
};
</script>
<template>
<component
:is="edit ? UpdateDialog : CreateDialog"
:show="show"
:title="edit ? 'Spremeni naslov' : 'Dodaj novi naslov'"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title> Naslov </template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="address">
<FormItem>
<FormLabel>Naslov</FormLabel>
<FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="country">
<FormItem>
<FormLabel>Država</FormLabel>
<FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="post_code">
<FormItem>
<FormLabel>Poštna številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="city">
<FormItem>
<FormLabel>Mesto</FormLabel>
<FormControl>
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
</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>
<SelectItem v-for="type in types" :key="type.id" :value="type.id">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</component>
</template>
@@ -0,0 +1,217 @@
<script setup>
import { ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({
show: { type: Boolean, default: false },
person: Object,
types: Array,
id: { type: Number, default: 0 },
});
const processing = ref(false);
const emit = defineEmits(["close"]);
const formSchema = toTypedSchema(
z.object({
address: z.string().min(1, "Naslov je obvezen."),
country: z.string().optional(),
post_code: z.string().optional(),
city: z.string().optional(),
type_id: z.number().nullable(),
description: z.string().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
address: "",
country: "",
post_code: "",
city: "",
type_id: props.types?.[0]?.id ?? null,
description: "",
},
});
const close = () => {
emit("close");
setTimeout(() => {
form.resetForm();
processing.value = false;
}, 300);
};
const resetForm = () => {
form.resetForm({
values: {
address: "",
country: "",
post_code: "",
city: "",
type_id: props.types?.[0]?.id ?? null,
description: "",
},
});
};
const hydrate = () => {
const id = props.id;
if (id) {
const a = (props.person.addresses || []).find((x) => x.id === id);
if (a) {
form.setValues({
address: a.address || "",
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
description: a.description || "",
});
return;
}
}
resetForm();
};
watch(() => props.id, () => hydrate(), { immediate: true });
watch(() => props.show, (v) => {
if (v) hydrate();
});
const update = async () => {
processing.value = true;
const { values } = form;
router.put(
route("person.address.update", { person: props.person, address_id: props.id }),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const onSubmit = form.handleSubmit(() => {
update();
});
const onConfirm = () => {
onSubmit();
};
</script>
<template>
<UpdateDialog
:show="show"
title="Spremeni naslov"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title>Naslov</template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="address">
<FormItem>
<FormLabel>Naslov</FormLabel>
<FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="country">
<FormItem>
<FormLabel>Država</FormLabel>
<FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="post_code">
<FormItem>
<FormLabel>Poštna številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="city">
<FormItem>
<FormLabel>Mesto</FormLabel>
<FormControl>
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
</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>
<SelectItem v-for="type in types" :key="type.id" :value="type.id">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</UpdateDialog>
</template>
@@ -0,0 +1,234 @@
<script setup>
import { computed, ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Checkbox } from "@/Components/ui/checkbox";
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
types: { type: Array, default: () => [] },
edit: { type: Boolean, default: false },
id: { type: Number, default: 0 },
isClientContext: { type: Boolean, default: false },
});
const emit = defineEmits(["close"]);
// Zod schema for form validation
const formSchema = toTypedSchema(
z.object({
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
label: z.string().optional(),
receive_auto_mails: z.boolean().optional(),
})
);
// VeeValidate form
const form = useForm({
validationSchema: formSchema,
initialValues: {
value: "",
label: "",
receive_auto_mails: false,
},
});
const processing = ref(false);
const close = () => {
emit("close");
setTimeout(() => {
form.resetForm();
processing.value = false;
}, 0);
};
const resetForm = () => {
form.resetForm({
values: {
value: "",
label: "",
receive_auto_mails: false,
},
});
};
const create = async () => {
processing.value = true;
const { values } = form;
router.post(
route("person.email.create", props.person),
values,
{
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
onError: (errors) => {
// Map Inertia errors to VeeValidate field errors
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const update = async () => {
processing.value = true;
const { values } = form;
router.put(
route("person.email.update", { person: props.person, email_id: props.id }),
values,
{
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
onError: (errors) => {
// Map Inertia errors to VeeValidate field errors
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
watch(
() => props.show,
(newVal) => {
if (!newVal) {
return;
}
if (props.edit && props.id) {
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
const email = list.find((e) => e.id === props.id);
if (email) {
form.setValues({
value: email.value ?? email.email ?? email.address ?? "",
label: email.label ?? "",
receive_auto_mails: !!email.receive_auto_mails,
});
} else {
resetForm();
}
} else {
resetForm();
}
}
);
const onSubmit = form.handleSubmit((values) => {
if (props.edit) {
update();
} else {
create();
}
});
const onConfirm = () => {
onSubmit();
};
</script>
<template>
<component
:is="edit ? UpdateDialog : CreateDialog"
:show="show"
:title="edit ? 'Spremeni email' : 'Dodaj email'"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title>Email</template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="value">
<FormItem>
<FormLabel>E-pošta</FormLabel>
<FormControl>
<Input
type="email"
placeholder="example@example.com"
autocomplete="email"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="label">
<FormItem>
<FormLabel>Oznaka (neobvezno)</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Oznaka"
autocomplete="off"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-if="props.person?.client || isClientContext"
v-slot="{ value, handleChange }"
name="receive_auto_mails"
>
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
:checked="value"
@update:checked="handleChange"
/>
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">
Prejemaj samodejna e-sporočila
</FormLabel>
</div>
</FormItem>
</FormField>
</div>
</form>
</component>
</template>
@@ -0,0 +1,27 @@
<script setup>
// This component reuses EmailCreateForm's logic via props.edit=true
import EmailCreateForm from "./EmailCreateForm.vue";
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
types: { type: Array, default: () => [] },
id: { type: Number, default: 0 },
// Pass-through to show the auto-mail checkbox for clients
isClientContext: { type: Boolean, default: false },
});
const emit = defineEmits(["close"]);
</script>
<template>
<EmailCreateForm
:show="show"
:person="person"
:types="types"
:edit="true"
:id="id"
:is-client-context="isClientContext"
@close="emit('close')"
/>
</template>
@@ -0,0 +1,85 @@
<script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
});
const emit = defineEmits(['add', 'edit', 'delete']);
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
</script>
<template>
<div class="flex justify-end mb-3" v-if="edit">
<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
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="address in person.addresses"
:key="address.id"
>
<div class="flex items-start justify-between mb-2">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{{ address.country }}
</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ address.type.name }}
</span>
</div>
<div v-if="edit">
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(address.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(address.id, address.address)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed">
{{
address.post_code && address.city
? `${address.address}, ${address.post_code} ${address.city}`
: address.address
}}
</p>
</div>
</div>
</template>
@@ -0,0 +1,97 @@
<script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
});
const emit = defineEmits(['add', 'edit', 'delete']);
const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []);
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
</script>
<template>
<div class="flex justify-end mb-3" v-if="edit">
<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">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="(email, idx) in getEmails(person)"
:key="idx"
>
<div class="flex items-start justify-between mb-2" v-if="edit">
<div class="flex flex-wrap gap-2">
<span
v-if="email?.label"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
{{ email.label }}
</span>
<span
v-else
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
>
Email
</span>
</div>
<div v-if="edit">
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(email.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(email.id, email?.value || email?.email || email?.address)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed">
{{ email?.value || email?.email || email?.address || "-" }}
</p>
<p v-if="email?.note" class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
{{ email.note }}
</p>
</div>
</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">
Ni e-poštnih naslovov.
</p>
</div>
</template>
@@ -0,0 +1,455 @@
<script setup>
import { ref, computed } from "vue";
import axios from "axios";
import { router } from "@inertiajs/vue3";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import PersonUpdateForm from "./PersonUpdateForm.vue";
import AddressCreateForm from "./AddressCreateForm.vue";
import AddressUpdateForm from "./AddressUpdateForm.vue";
import PhoneCreateForm from "./PhoneCreateForm.vue";
import PhoneUpdateForm from "./PhoneUpdateForm.vue";
import EmailCreateForm from "./EmailCreateForm.vue";
import EmailUpdateForm from "./EmailUpdateForm.vue";
import TrrCreateForm from "./TrrCreateForm.vue";
import TrrUpdateForm from "./TrrUpdateForm.vue";
import ConfirmDialog from "../ConfirmDialog.vue";
// Tab components
import PersonInfoPersonTab from "./PersonInfoPersonTab.vue";
import PersonInfoAddressesTab from "./PersonInfoAddressesTab.vue";
import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
import Separator from "../ui/separator/Separator.vue";
const props = defineProps({
person: Object,
personEdit: {
type: Boolean,
default: true,
},
edit: {
type: Boolean,
default: true,
},
tabColor: {
type: String,
default: "blue-600",
},
types: {
type: Object,
default: {
address_types: [],
phone_types: [],
},
},
enableSms: { type: Boolean, default: false },
clientCaseUuid: { type: String, default: null },
smsProfiles: { type: Array, default: () => [] },
smsSenders: { type: Array, default: () => [] },
smsTemplates: { type: Array, default: () => [] },
});
// Dialog states
const drawerUpdatePerson = ref(false);
const drawerAddAddress = ref(false);
const drawerAddPhone = ref(false);
const drawerAddEmail = ref(false);
const drawerAddTrr = ref(false);
// Edit states
const editAddress = ref(false);
const editAddressId = ref(0);
const editPhone = ref(false);
const editPhoneId = ref(0);
const editEmail = ref(false);
const editEmailId = ref(0);
const editTrr = ref(false);
const editTrrId = ref(0);
// Confirm dialog state
const confirm = ref({
show: false,
title: "Potrditev brisanja",
message: "",
type: "",
id: 0,
itemName: null,
});
// SMS dialog state
const showSmsDialog = ref(false);
const smsTargetPhone = ref(null);
// Person handlers
const openDrawerUpdateClient = () => {
drawerUpdatePerson.value = true;
};
// Address handlers
const openDrawerAddAddress = (edit = false, id = 0) => {
drawerAddAddress.value = true;
editAddress.value = edit;
editAddressId.value = id;
};
const closeDrawerAddAddress = () => {
drawerAddAddress.value = false;
editAddress.value = false;
editAddressId.value = 0;
};
// Phone handlers
const openDrawerAddPhone = (edit = false, id = 0) => {
editPhone.value = edit;
editPhoneId.value = id;
drawerAddPhone.value = true;
};
// Keep the old name for backward compatibility if needed, but use the correct name
const operDrawerAddPhone = openDrawerAddPhone;
const closeDrawerAddPhone = () => {
drawerAddPhone.value = false;
editPhone.value = false;
editPhoneId.value = 0;
};
// Email handlers
const openDrawerAddEmail = (edit = false, id = 0) => {
drawerAddEmail.value = true;
editEmail.value = edit;
editEmailId.value = id;
};
// TRR handlers
const openDrawerAddTrr = (edit = false, id = 0) => {
drawerAddTrr.value = true;
editTrr.value = edit;
editTrrId.value = id;
};
// Confirm dialog handlers
const openConfirm = (type, id, label = "") => {
confirm.value = {
show: true,
title: "Potrditev brisanja",
message: label
? `Ali res želite izbrisati "${label}"?`
: "Ali res želite izbrisati izbran element?",
type,
id,
itemName: label || null,
};
};
const closeConfirm = () => {
confirm.value.show = false;
confirm.value.itemName = null;
};
const onConfirmDelete = async () => {
const { type, id } = confirm.value;
try {
if (type === "email") {
await axios.delete(
route("person.email.delete", { person: props.person, email_id: id })
);
const list = props.person.emails || [];
const idx = list.findIndex((e) => e.id === id);
if (idx !== -1) list.splice(idx, 1);
closeConfirm();
} else if (type === "trr") {
await axios.delete(
route("person.trr.delete", { person: props.person, trr_id: id })
);
let list =
props.person.trrs ||
props.person.bank_accounts ||
props.person.accounts ||
props.person.bankAccounts ||
[];
const idx = list.findIndex((a) => a.id === id);
if (idx !== -1) list.splice(idx, 1);
closeConfirm();
} else if (type === "address") {
await axios.delete(
route("person.address.delete", { person: props.person, address_id: id })
);
const list = props.person.addresses || [];
const idx = list.findIndex((a) => a.id === id);
if (idx !== -1) list.splice(idx, 1);
closeConfirm();
} else if (type === "phone") {
router.delete(
route("person.phone.delete", { person: props.person, phone_id: id }),
{
preserveScroll: true,
onSuccess: () => {
closeConfirm();
},
onError: (errors) => {
console.error("Delete failed", errors);
closeConfirm();
},
}
);
}
} catch (e) {
console.error("Delete failed", e?.response || e);
closeConfirm();
}
};
// SMS handlers
const openSmsDialog = (phone) => {
if (!props.enableSms || !props.clientCaseUuid) return;
smsTargetPhone.value = phone;
showSmsDialog.value = true;
};
const closeSmsDialog = () => {
showSmsDialog.value = false;
smsTargetPhone.value = null;
};
// Tab event handlers
const handlePersonEdit = () => openDrawerUpdateClient();
const handleAddressAdd = () => openDrawerAddAddress(false, 0);
const handleAddressEdit = (id) => openDrawerAddAddress(true, id);
const handleAddressDelete = (id, label) => openConfirm("address", id, label);
const handlePhoneAdd = () => openDrawerAddPhone(false, 0);
const handlePhoneEdit = (id) => openDrawerAddPhone(true, id);
const handlePhoneDelete = (id, label) => openConfirm("phone", id, label);
const handlePhoneSms = (phone) => openSmsDialog(phone);
const handleEmailAdd = () => openDrawerAddEmail(false, 0);
const handleEmailEdit = (id) => openDrawerAddEmail(true, id);
const handleEmailDelete = (id, label) => openConfirm("email", id, label);
const handleTrrAdd = () => openDrawerAddTrr(false, 0);
const handleTrrEdit = (id) => openDrawerAddTrr(true, id);
const handleTrrDelete = (id, label) => openConfirm("trr", id, label);
// Computed counts for badges
const addressesCount = computed(() => (props.person?.addresses || []).length);
const phonesCount = computed(() => (props.person?.phones || []).length);
const emailsCount = computed(() => (props.person?.emails || []).length);
const trrsCount = computed(() => {
const list = props.person?.trrs ||
props.person?.bank_accounts ||
props.person?.accounts ||
props.person?.bankAccounts || [];
return list.length;
});
// Format badge count (show 999+ if >= 999)
const formatBadgeCount = (count) => {
return count >= 999 ? '999+' : String(count);
};
</script>
<template>
<Tabs default-value="person" class="mt-2">
<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="addresses" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>Naslovi</span>
<span
v-if="addressesCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(addressesCount) }}
</span>
</div>
</TabsTrigger>
<TabsTrigger value="phones" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>Telefonske</span>
<span
v-if="phonesCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(phonesCount) }}
</span>
</div>
</TabsTrigger>
<TabsTrigger value="emails" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>Email</span>
<span
v-if="emailsCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(emailsCount) }}
</span>
</div>
</TabsTrigger>
<TabsTrigger value="trr" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>TRR</span>
<span
v-if="trrsCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(trrsCount) }}
</span>
</div>
</TabsTrigger>
</TabsList>
<TabsContent value="person" class="py-2">
<PersonInfoPersonTab
:person="person"
:edit="edit"
:person-edit="personEdit"
@edit="handlePersonEdit"
/>
</TabsContent>
<TabsContent value="addresses" class="py-4">
<PersonInfoAddressesTab
:person="person"
:edit="edit"
@add="handleAddressAdd"
@edit="handleAddressEdit"
@delete="handleAddressDelete"
/>
</TabsContent>
<TabsContent value="phones" class="py-4">
<PersonInfoPhonesTab
:person="person"
:edit="edit"
:enable-sms="enableSms && !!clientCaseUuid"
@add="handlePhoneAdd"
@edit="handlePhoneEdit"
@delete="handlePhoneDelete"
@sms="handlePhoneSms"
/>
</TabsContent>
<TabsContent value="emails" class="py-4">
<PersonInfoEmailsTab
:person="person"
:edit="edit"
@add="handleEmailAdd"
@edit="handleEmailEdit"
@delete="handleEmailDelete"
/>
</TabsContent>
<TabsContent value="trr" class="py-4">
<PersonInfoTrrTab
:person="person"
:edit="edit"
@add="handleTrrAdd"
@edit="handleTrrEdit"
@delete="handleTrrDelete"
/>
</TabsContent>
</Tabs>
<!-- Person Update Dialog -->
<PersonUpdateForm
:show="drawerUpdatePerson"
@close="drawerUpdatePerson = false"
:person="person"
/>
<!-- Address Dialogs -->
<AddressCreateForm
:show="drawerAddAddress && !editAddress"
@close="closeDrawerAddAddress"
:person="person"
:types="types.address_types"
:id="editAddressId"
:edit="editAddress"
/>
<AddressUpdateForm
:show="drawerAddAddress && editAddress"
@close="closeDrawerAddAddress"
:person="person"
:types="types.address_types"
:id="editAddressId"
/>
<!-- Phone Dialogs -->
<PhoneCreateForm
:show="drawerAddPhone && !editPhone"
@close="closeDrawerAddPhone"
:person="person"
:types="types.phone_types"
/>
<PhoneUpdateForm
:show="drawerAddPhone && editPhone"
@close="closeDrawerAddPhone"
:person="person"
:types="types.phone_types"
:id="editPhoneId"
/>
<!-- Email Dialogs -->
<EmailCreateForm
:show="drawerAddEmail && !editEmail"
@close="drawerAddEmail = false"
:person="person"
:types="types.email_types ?? []"
:is-client-context="!!person?.client"
/>
<EmailUpdateForm
:show="drawerAddEmail && editEmail"
@close="drawerAddEmail = false"
:person="person"
:types="types.email_types ?? []"
:id="editEmailId"
:is-client-context="!!person?.client"
/>
<!-- TRR Dialogs -->
<TrrCreateForm
:show="drawerAddTrr && !editTrr"
@close="drawerAddTrr = false"
:person="person"
:types="types.trr_types ?? []"
:banks="types.banks ?? []"
:currencies="types.currencies ?? ['EUR']"
/>
<TrrUpdateForm
:show="drawerAddTrr && editTrr"
@close="drawerAddTrr = false"
:person="person"
:types="types.trr_types ?? []"
:banks="types.banks ?? []"
:currencies="types.currencies ?? ['EUR']"
:id="editTrrId"
/>
<!-- Confirm Deletion Dialog -->
<ConfirmDialog
:show="confirm.show"
:title="confirm.title"
:message="confirm.message"
:item-name="confirm.itemName"
confirm-text="Izbriši"
cancel-text="Prekliči"
:danger="true"
@close="closeConfirm"
@confirm="onConfirmDelete"
/>
<!-- SMS Dialog -->
<PersonInfoSmsDialog
v-if="clientCaseUuid"
:show="showSmsDialog"
:phone="smsTargetPhone"
:client-case-uuid="clientCaseUuid"
:sms-profiles="smsProfiles"
:sms-senders="smsSenders"
:sms-templates="smsTemplates"
@close="closeSmsDialog"
/>
</template>
@@ -0,0 +1,93 @@
<script setup>
import { UserEditIcon } from "@/Utilities/Icons";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
personEdit: { type: Boolean, default: true },
});
const emit = defineEmits(['edit']);
const getMainAddress = (adresses) => {
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
if (addr !== "") {
const tail = addr.post_code && addr.city ? `, ${addr.post_code} ${addr.city}` : "";
const country = addr.country !== "" ? ` - ${addr.country}` : "";
return addr.address !== "" ? addr.address + tail + country : "";
}
return "";
};
const getMainPhone = (phones) => {
const pho = phones.filter((a) => a.type.id === 1)[0] ?? "";
if (pho !== "") {
const countryCode = pho.country_code !== null ? `+${pho.country_code} ` : "";
return pho.nu !== "" ? countryCode + pho.nu : "";
}
return "";
};
const handleEdit = () => {
emit('edit');
};
</script>
<template>
<div class="flex justify-end mb-3">
<button
v-if="edit && personEdit"
@click="handleEdit"
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 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
title="Uredi osebo"
>
<UserEditIcon size="md" />
<span>Uredi</span>
</button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Nu.</p>
<p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Name.</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.full_name }}
</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Tax NU.</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.tax_number }}
</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Social security NU.</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.social_security_number }}
</p>
</div>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Address</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainAddress(person.addresses) }}
</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Phone</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainPhone(person.phones) }}
</p>
</div>
<div class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Description</p>
<p class="text-sm font-medium text-gray-900">
{{ person.description }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,98 @@
<script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
enableSms: { type: Boolean, default: false },
});
const emit = defineEmits(['add', 'edit', 'delete', 'sms']);
const getPhones = (p) => (Array.isArray(p?.phones) ? p.phones : []);
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
const handleSms = (phone) => emit('sms', phone);
</script>
<template>
<div class="flex justify-end mb-3" v-if="edit">
<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">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="phone in getPhones(person)"
:key="phone.id"
>
<div class="flex items-start justify-between mb-2">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
+{{ phone.country_code }}
</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ phone && phone.type && phone.type.name ? phone.type.name : "—" }}
</span>
</div>
<div class="flex items-center gap-1">
<!-- Send SMS only in ClientCase person context -->
<button
v-if="enableSms"
@click="handleSms(phone)"
title="Pošlji SMS"
class="px-2.5 py-1 text-xs font-medium text-indigo-700 bg-indigo-50 border border-indigo-200 hover:bg-indigo-100 rounded-lg transition-colors"
>
SMS
</button>
<Dropdown v-if="edit" align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(phone.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(phone.id, phone.nu)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed">{{ phone.nu }}</p>
</div>
</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">
Ni telefonov.
</p>
</div>
</template>
@@ -0,0 +1,480 @@
<script setup>
import { ref, watch, computed } from "vue";
import DialogModal from "@/Components/DialogModal.vue";
import { router, usePage } from "@inertiajs/vue3";
const props = defineProps({
show: { type: Boolean, default: false },
phone: Object,
clientCaseUuid: { type: String, default: null },
smsProfiles: { type: Array, default: () => [] },
smsSenders: { type: Array, default: () => [] },
smsTemplates: { type: Array, default: () => [] },
});
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 pageProps = computed(() => page?.props ?? {});
const pageSmsProfiles = computed(() => {
const fromProps =
Array.isArray(props.smsProfiles) && props.smsProfiles.length
? props.smsProfiles
: null;
return fromProps ?? pageProps.value?.sms_profiles ?? [];
});
const pageSmsSenders = computed(() => {
const fromProps =
Array.isArray(props.smsSenders) && props.smsSenders.length ? props.smsSenders : null;
return fromProps ?? pageProps.value?.sms_senders ?? [];
});
const pageSmsTemplates = computed(() => {
const fromProps =
Array.isArray(props.smsTemplates) && props.smsTemplates.length
? props.smsTemplates
: null;
return fromProps ?? pageProps.value?.sms_templates ?? [];
});
// Helpers: EU formatter and token renderer
const formatEu = (value, decimals = 2) => {
if (value === null || value === undefined || value === "") {
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(0);
}
const num =
typeof value === "number"
? value
: parseFloat(String(value).replace(/\./g, "").replace(",", "."));
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(isNaN(num) ? 0 : num);
};
const renderTokens = (text, vars) => {
if (!text) return "";
const resolver = (obj, path) => {
if (!obj) return null;
if (Object.prototype.hasOwnProperty.call(obj, path)) return obj[path];
const segs = path.split(".");
let cur = obj;
for (const s of segs) {
if (cur && typeof cur === "object" && s in cur) {
cur = cur[s];
} else {
return null;
}
}
return cur;
};
return text.replace(/\{([a-zA-Z0-9_\.]+)\}/g, (_, key) => {
const val = resolver(vars, key);
return val !== null && val !== undefined ? String(val) : `{${key}}`;
});
};
// 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) => {
if (!text) return "";
if (limit <= 0) return "";
if (encoding === "UCS-2") {
return text.slice(0, limit);
}
let acc = 0;
let out = "";
for (const ch of text) {
const cost = ch === "€" || GSM7_EXTENDED.has(ch) ? 2 : 1;
if (acc + cost > limit) break;
out += ch;
acc += cost;
}
return out;
};
watch(smsMessage, (val) => {
const limit = maxAllowed.value;
if (charCount.value > limit) {
smsMessage.value = truncateToLimit(val, limit, smsEncoding.value);
}
});
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(() => {
if (!selectedProfileId.value) return pageSmsSenders.value;
return (pageSmsSenders.value || []).filter(
(s) => s.profile_id === selectedProfileId.value
);
});
watch(selectedProfileId, () => {
if (!selectedSenderId.value) return;
const ok = sendersForSelectedProfile.value.some((s) => s.id === selectedSenderId.value);
if (!ok) selectedSenderId.value = null;
});
watch(sendersForSelectedProfile, (list) => {
if (!Array.isArray(list)) return;
if (!selectedSenderId.value && list.length > 0) {
selectedSenderId.value = list[0].id;
}
});
const buildVarsFromSelectedContract = () => {
const uuid = selectedContractUuid.value;
if (!uuid) return {};
const c = (contractsForCase.value || []).find((x) => x.uuid === uuid);
if (!c) return {};
const vars = {
contract: {
uuid: c.uuid,
reference: c.reference,
start_date: c.start_date || "",
end_date: c.end_date || "",
},
};
if (c.account) {
vars.account = {
reference: c.account.reference,
type: c.account.type,
initial_amount:
c.account.initial_amount ??
(c.account.initial_amount_raw ? formatEu(c.account.initial_amount_raw) : null),
balance_amount:
c.account.balance_amount ??
(c.account.balance_amount_raw ? formatEu(c.account.balance_amount_raw) : null),
initial_amount_raw: c.account.initial_amount_raw ?? null,
balance_amount_raw: c.account.balance_amount_raw ?? null,
};
}
return vars;
};
const updateSmsFromSelection = async () => {
if (!selectedTemplateId.value) return;
try {
const url = route("clientCase.sms.preview", { client_case: props.clientCaseUuid });
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN":
document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ||
"",
},
body: JSON.stringify({
template_id: selectedTemplateId.value,
contract_uuid: selectedContractUuid.value || null,
}),
credentials: "same-origin",
});
if (res.ok) {
const data = await res.json();
if (typeof data?.content === "string" && data.content.trim() !== "") {
smsMessage.value = data.content;
return;
}
}
} catch (e) {
// ignore and fallback
}
const tpl = (pageSmsTemplates.value || []).find(
(t) => t.id === selectedTemplateId.value
);
if (tpl && typeof tpl.content === "string") {
smsMessage.value = renderTokens(tpl.content, buildVarsFromSelectedContract());
}
};
watch(selectedTemplateId, () => {
if (!selectedTemplateId.value) return;
updateSmsFromSelection();
});
watch(selectedContractUuid, () => {
if (!selectedTemplateId.value) return;
updateSmsFromSelection();
});
watch(pageSmsTemplates, (list) => {
if (!Array.isArray(list)) return;
if (!selectedTemplateId.value && list.length > 0) {
selectedTemplateId.value = list[0].id;
}
});
const loadContractsForCase = async () => {
try {
const url = route("clientCase.contracts.list", { client_case: props.clientCaseUuid });
const res = await fetch(url, {
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin",
});
const json = await res.json();
contractsForCase.value = Array.isArray(json?.data) ? json.data : [];
} catch (e) {
contractsForCase.value = [];
}
};
watch(
() => props.show,
(newVal) => {
if (newVal) {
smsMessage.value = "";
selectedProfileId.value =
(pageSmsProfiles.value && pageSmsProfiles.value[0]?.id) || null;
if (selectedProfileId.value) {
const prof = (pageSmsProfiles.value || []).find(
(p) => p.id === selectedProfileId.value
);
if (prof && prof.default_sender_id) {
const inList = sendersForSelectedProfile.value.find(
(s) => s.id === prof.default_sender_id
);
selectedSenderId.value = inList ? prof.default_sender_id : null;
} else {
selectedSenderId.value = null;
}
} else {
selectedSenderId.value = null;
}
deliveryReport.value = false;
selectedTemplateId.value =
(pageSmsTemplates.value && pageSmsTemplates.value[0]?.id) || null;
loadContractsForCase();
}
}
);
const closeSmsDialog = () => {
emit('close');
};
const submitSms = () => {
if (!props.phone || !smsMessage.value || !props.clientCaseUuid) {
return;
}
smsSending.value = true;
router.post(
route("clientCase.phone.sms", {
client_case: props.clientCaseUuid,
phone_id: props.phone.id,
}),
{
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,
onFinish: () => {
smsSending.value = false;
closeSmsDialog();
},
}
);
};
</script>
<template>
<DialogModal :show="show" @close="closeSmsDialog">
<template #title>Pošlji SMS</template>
<template #content>
<div class="space-y-2">
<p class="text-sm text-gray-600">
Prejemnik: <span class="font-mono">{{ phone?.nu }}</span>
<span v-if="phone?.country_code" class="ml-2 text-xs text-gray-500"
>CC +{{ phone.country_code }}</span
>
</p>
<!-- 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>
</div>
<!-- Template selector -->
<div>
<label class="block text-sm font-medium text-gray-700">Predloga</label>
<select
v-model="selectedTemplateId"
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="t in pageSmsTemplates" :key="t.id" :value="t.id">
{{ t.name || "Predloga #" + t.id }}
</option>
</select>
</div>
<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 -->
<div class="mt-1 text-xs text-gray-600 flex flex-col gap-1">
<div>
<span class="font-medium">Znakov:</span>
<span class="font-mono">{{ charCount }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Kodiranje:</span>
<span>{{ smsEncoding }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Deli SMS:</span>
<span class="font-mono">{{ segments }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Krediti:</span>
<span class="font-mono">{{ creditsNeeded }}</span>
</div>
<div>
<span class="font-medium">Omejitev:</span>
<span class="font-mono">{{ maxAllowed }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Preostanek:</span>
<span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">{{
remaining
}}</span>
</div>
<p class="text-[11px] text-gray-500 leading-snug">
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake, ki
ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS2). V tem
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in )
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM7) oziroma
320 (UCS2) znakov.
</p>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mt-1">
<input
type="checkbox"
v-model="deliveryReport"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
Zahtevaj poročilo o dostavi
</label>
</div>
</template>
<template #footer>
<button class="px-3 py-1 rounded border mr-2" @click="closeSmsDialog">
Prekliči
</button>
<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>
@@ -0,0 +1,116 @@
<script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
});
const emit = defineEmits(['add', 'edit', 'delete']);
const getTRRs = (p) => {
if (Array.isArray(p?.trrs)) return p.trrs;
if (Array.isArray(p?.bank_accounts)) return p.bank_accounts;
if (Array.isArray(p?.accounts)) return p.accounts;
if (Array.isArray(p?.bankAccounts)) return p.bankAccounts;
return [];
};
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
</script>
<template>
<div class="flex justify-end mb-3" v-if="edit">
<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">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="(acc, idx) in getTRRs(person)"
:key="idx"
>
<div class="flex items-start justify-between mb-2" v-if="edit">
<div class="flex flex-wrap gap-2">
<span
v-if="acc?.bank_name"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
{{ acc.bank_name }}
</span>
<span
v-if="acc?.holder_name"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
>
{{ acc.holder_name }}
</span>
<span
v-if="acc?.currency"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
>
{{ acc.currency }}
</span>
</div>
<div v-if="edit">
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(acc.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(acc.id, acc?.iban || acc?.account_number)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed font-mono">
{{
acc?.iban ||
acc?.account_number ||
acc?.account ||
acc?.nu ||
acc?.number ||
"-"
}}
</p>
<p v-if="acc?.notes" class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
{{ acc.notes }}
</p>
</div>
</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">
Ni TRR računov.
</p>
</div>
</template>
@@ -0,0 +1,183 @@
<script setup>
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import axios from 'axios';
import { ref } from 'vue';
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
const props = defineProps({
show: {
type: Boolean,
default: false
},
person: Object
});
const processingUpdate = ref(false);
const emit = defineEmits(['close']);
const formSchema = toTypedSchema(
z.object({
full_name: z.string().min(1, "Naziv je obvezen."),
tax_number: z.string().optional(),
social_security_number: z.string().optional(),
description: z.string().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
full_name: props.person?.full_name || '',
tax_number: props.person?.tax_number || '',
social_security_number: props.person?.social_security_number || '',
description: props.person?.description || ''
},
});
const close = () => {
emit('close');
setTimeout(() => {
form.resetForm({
values: {
full_name: props.person?.full_name || '',
tax_number: props.person?.tax_number || '',
social_security_number: props.person?.social_security_number || '',
description: props.person?.description || ''
}
});
}, 500);
}
const updatePerson = async () => {
processingUpdate.value = true;
const { values } = form;
try {
const response = await axios({
method: 'put',
url: route('person.update', props.person),
data: values
});
props.person.full_name = response.data.person.full_name;
props.person.tax_number = response.data.person.tax_number;
props.person.social_security_number = response.data.person.social_security_number;
props.person.description = response.data.person.description;
processingUpdate.value = false;
close();
} catch (reason) {
const errors = reason.response?.data?.errors || {};
// Map axios errors to VeeValidate field errors
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processingUpdate.value = false;
}
}
const onSubmit = form.handleSubmit(() => {
updatePerson();
});
const onConfirm = () => {
onSubmit();
}
</script>
<template>
<UpdateDialog
:show="show"
:title="`Posodobi ${person.full_name}`"
confirm-text="Shrani"
:processing="processingUpdate"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title>
Oseba
</template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="full_name">
<FormItem>
<FormLabel>Naziv</FormLabel>
<FormControl>
<Input
id="cfullname"
type="text"
placeholder="Naziv"
autocomplete="full-name"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="tax_number">
<FormItem>
<FormLabel>Davčna</FormLabel>
<FormControl>
<Input
id="ctaxnumber"
type="text"
placeholder="Davčna številka"
autocomplete="tax-number"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="social_security_number">
<FormItem>
<FormLabel>Matična / Emšo</FormLabel>
<FormControl>
<Input
id="csocialSecurityNumber"
type="text"
placeholder="Matična / Emšo"
autocomplete="social-security-number"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Textarea
id="cdescription"
placeholder="Opis"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</UpdateDialog>
</template>
@@ -0,0 +1,229 @@
<script setup>
import { computed, ref } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
person: Object,
types: Array,
});
const emit = defineEmits(["close"]);
const formSchema = toTypedSchema(
z.object({
nu: z.string().min(1, "Številka je obvezna."),
country_code: z.number().default(386),
type_id: z.number().nullable(),
description: z.string().optional(),
validated: z.boolean().default(false),
phone_type: z.string().nullable().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
nu: "",
country_code: 386,
type_id: props.types?.[0]?.id ?? null,
description: "",
validated: false,
phone_type: null,
},
});
const processing = ref(false);
const close = () => {
emit("close");
setTimeout(() => {
form.resetForm();
processing.value = false;
}, 500);
};
const resetForm = () => {
form.resetForm({
values: {
nu: "",
country_code: 386,
type_id: props.types?.[0]?.id ?? null,
description: "",
validated: false,
phone_type: null,
},
});
};
const countryOptions = [
{ value: 386, label: "+386 (Slovenija)" },
{ value: 385, label: "+385 (Hrvaška)" },
{ value: 39, label: "+39 (Italija)" },
{ value: 36, label: "+36 (Madžarska)" },
{ value: 43, label: "+43 (Avstrija)" },
{ value: 381, label: "+381 (Srbija)" },
{ value: 387, label: "+387 (Bosna in Hercegovina)" },
{ value: 382, label: "+382 (Črna gora)" },
];
const phoneTypeOptions = [
{ value: null, label: "—" },
{ value: "mobile", label: "Mobilni" },
{ value: "landline", label: "Stacionarni" },
{ value: "voip", label: "VOIP" },
];
const create = async () => {
processing.value = true;
const { values } = form;
router.post(
route("person.phone.create", props.person),
values,
{
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const onSubmit = form.handleSubmit(() => {
create();
});
</script>
<template>
<CreateDialog
:show="show"
title="Dodaj novi telefon"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onSubmit"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title> Telefon </template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="nu">
<FormItem>
<FormLabel>Številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="country_code">
<FormItem>
<FormLabel>Koda države tel.</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi kodo države" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<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>
<SelectItem v-for="type in types" :key="type.id" :value="type.id">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="phone_type">
<FormItem>
<FormLabel>Vrsta telefona (enum)</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi vrsto" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox :checked="value" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Potrjeno</FormLabel>
</div>
</FormItem>
</FormField>
</div>
</form>
</CreateDialog>
</template>
@@ -0,0 +1,254 @@
<script setup>
import { ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
person: Object,
types: Array,
id: {
type: Number,
default: 0,
},
});
const emit = defineEmits(["close"]);
const formSchema = toTypedSchema(
z.object({
nu: z.string().min(1, "Številka je obvezna."),
country_code: z.number().default(386),
type_id: z.number().nullable(),
description: z.string().optional(),
validated: z.boolean().default(false),
phone_type: z.string().nullable().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
nu: "",
country_code: 386,
type_id: props.types?.[0]?.id ?? null,
description: "",
validated: false,
phone_type: null,
},
});
const processing = ref(false);
const close = () => {
emit("close");
setTimeout(() => {
form.resetForm();
processing.value = false;
}, 500);
};
const resetForm = () => {
form.resetForm({
values: {
nu: "",
country_code: 386,
type_id: props.types?.[0]?.id ?? null,
description: "",
validated: false,
phone_type: null,
},
});
};
const countryOptions = [
{ value: 386, label: "+386 (Slovenija)" },
{ value: 385, label: "+385 (Hrvaška)" },
{ value: 39, label: "+39 (Italija)" },
{ value: 36, label: "+36 (Madžarska)" },
{ value: 43, label: "+43 (Avstrija)" },
{ value: 381, label: "+381 (Srbija)" },
{ value: 387, label: "+387 (Bosna in Hercegovina)" },
{ value: 382, label: "+382 (Črna gora)" },
];
const phoneTypeOptions = [
{ value: null, label: "—" },
{ value: "mobile", label: "Mobilni" },
{ value: "landline", label: "Stacionarni" },
{ value: "voip", label: "VOIP" },
];
function hydrateFromProps() {
if (props.id) {
const p = props.person?.phones?.find((x) => x.id === props.id);
if (p) {
form.setValues({
nu: p.nu || "",
country_code: p.country_code ?? 386,
type_id: p.type_id ?? (props.types?.[0]?.id ?? null),
description: p.description || "",
validated: !!p.validated,
phone_type: p.phone_type ?? null,
});
return;
}
}
resetForm();
}
watch(() => props.id, () => hydrateFromProps(), { immediate: true });
watch(() => props.show, (val) => { if (val) hydrateFromProps(); });
const update = async () => {
processing.value = true;
const { values } = form;
router.put(
route("person.phone.update", { person: props.person, phone_id: props.id }),
values,
{
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const onSubmit = form.handleSubmit(() => {
update();
});
</script>
<template>
<UpdateDialog
:show="show"
title="Spremeni telefon"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onSubmit"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title> Telefon </template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="nu">
<FormItem>
<FormLabel>Številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="country_code">
<FormItem>
<FormLabel>Koda države tel.</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi kodo države" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<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>
<SelectItem v-for="type in types" :key="type.id" :value="type.id">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="phone_type">
<FormItem>
<FormLabel>Vrsta telefona (enum)</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi vrsto" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox :checked="value" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Potrjeno</FormLabel>
</div>
</FormItem>
</FormField>
</div>
</form>
</UpdateDialog>
</template>
@@ -0,0 +1,324 @@
<script setup>
import { ref, watch } from 'vue';
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import axios from 'axios';
import CreateDialog from '../Dialogs/CreateDialog.vue';
import UpdateDialog from '../Dialogs/UpdateDialog.vue';
import SectionTitle from '../SectionTitle.vue';
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";
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
currencies: { type: Array, default: () => ['EUR'] },
edit: { type: Boolean, default: false },
id: { type: Number, default: 0 },
});
const processing = ref(false);
const errors = ref({});
const emit = defineEmits(['close']);
const initialCurrency = () => (props.currencies && props.currencies.length ? props.currencies[0] : 'EUR');
const formSchema = toTypedSchema(
z.object({
iban: z.string().optional(),
bank_name: z.string().optional(),
bic_swift: z.string().optional(),
account_number: z.string().optional(),
routing_number: z.string().optional(),
currency: z.string().default(initialCurrency()),
country_code: z.string().optional(),
holder_name: z.string().optional(),
notes: z.string().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
iban: '',
bank_name: '',
bic_swift: '',
account_number: '',
routing_number: '',
currency: initialCurrency(),
country_code: '',
holder_name: '',
notes: ''
},
});
const close = () => {
emit('close');
setTimeout(() => {
errors.value = {};
form.resetForm();
}, 300);
};
const resetForm = () => {
form.resetForm({
values: {
iban: '',
bank_name: '',
bic_swift: '',
account_number: '',
routing_number: '',
currency: initialCurrency(),
country_code: '',
holder_name: '',
notes: ''
}
});
};
const create = async () => {
processing.value = true;
errors.value = {};
const { values } = form;
try {
const { data } = await axios.post(route('person.trr.create', props.person), values);
if (!Array.isArray(props.person.trrs)) props.person.trrs = (props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || []);
(props.person.trrs).push(data.trr);
processing.value = false;
close();
resetForm();
} catch (e) {
errors.value = e?.response?.data?.errors || {};
// Map axios errors to VeeValidate field errors
if (errors.value) {
Object.keys(errors.value).forEach((field) => {
const errorMessages = Array.isArray(errors.value[field])
? errors.value[field]
: [errors.value[field]];
form.setFieldError(field, errorMessages[0]);
});
}
processing.value = false;
}
};
const update = async () => {
processing.value = true;
errors.value = {};
const { values } = form;
try {
const { data } = await axios.put(route('person.trr.update', { person: props.person, trr_id: props.id }), values);
let list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
const idx = list.findIndex(a => a.id === data.trr.id);
if (idx !== -1) list[idx] = data.trr;
processing.value = false;
close();
resetForm();
} catch (e) {
errors.value = e?.response?.data?.errors || {};
// Map axios errors to VeeValidate field errors
if (errors.value) {
Object.keys(errors.value).forEach((field) => {
const errorMessages = Array.isArray(errors.value[field])
? errors.value[field]
: [errors.value[field]];
form.setFieldError(field, errorMessages[0]);
});
}
processing.value = false;
}
};
watch(
() => props.id,
(id) => {
if (props.edit && id) {
const list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
const current = list.find(a => a.id === id);
if (current) {
form.setValues({
iban: current.iban || current.account_number || current.number || '',
bank_name: current.bank_name || '',
bic_swift: current.bic_swift || '',
account_number: current.account_number || '',
routing_number: current.routing_number || '',
currency: current.currency || initialCurrency(),
country_code: current.country_code || '',
holder_name: current.holder_name || '',
notes: current.notes || ''
});
return;
}
}
resetForm();
},
{ immediate: true }
);
watch(() => props.show, (val) => {
if (val && props.edit && props.id) {
const list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
const current = list.find(a => a.id === props.id);
if (current) {
form.setValues({
iban: current.iban || current.account_number || current.number || '',
bank_name: current.bank_name || '',
bic_swift: current.bic_swift || '',
account_number: current.account_number || '',
routing_number: current.routing_number || '',
currency: current.currency || initialCurrency(),
country_code: current.country_code || '',
holder_name: current.holder_name || '',
notes: current.notes || ''
});
}
} else if (val && !props.edit) {
resetForm();
}
});
const submit = () => (props.edit ? update() : create());
const onSubmit = form.handleSubmit(() => {
submit();
});
const onConfirm = () => {
onSubmit();
};
</script>
<template>
<component
:is="edit ? UpdateDialog : CreateDialog"
:show="show"
:title="edit ? 'Spremeni TRR' : 'Dodaj TRR'"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title>TRR</template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="iban">
<FormItem>
<FormLabel>IBAN</FormLabel>
<FormControl>
<Input id="trr_iban" placeholder="IBAN" autocomplete="off" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bank_name">
<FormItem>
<FormLabel>Banka</FormLabel>
<FormControl>
<Input id="trr_bank_name" placeholder="Banka" autocomplete="organization" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bic_swift">
<FormItem>
<FormLabel>BIC / SWIFT</FormLabel>
<FormControl>
<Input id="trr_bic" placeholder="BIC / SWIFT" autocomplete="off" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="account_number">
<FormItem>
<FormLabel>Številka računa</FormLabel>
<FormControl>
<Input id="trr_accnum" placeholder="Številka računa" autocomplete="off" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="routing_number">
<FormItem>
<FormLabel>Usmerjevalna številka (routing)</FormLabel>
<FormControl>
<Input id="trr_route" placeholder="Routing number" autocomplete="off" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-if="currencies && currencies.length" v-slot="{ value, handleChange }" name="currency">
<FormItem>
<FormLabel>Valuta</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi valuto" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="c in currencies" :key="c" :value="c">
{{ c }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="country_code">
<FormItem>
<FormLabel>Koda države (2-znaki, npr. SI)</FormLabel>
<FormControl>
<Input id="trr_cc" placeholder="SI" autocomplete="country" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="holder_name">
<FormItem>
<FormLabel>Imetnik računa</FormLabel>
<FormControl>
<Input id="trr_holder" placeholder="Imetnik računa" autocomplete="name" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="notes">
<FormItem>
<FormLabel>Opombe</FormLabel>
<FormControl>
<Textarea id="trr_notes" placeholder="Opombe" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</component>
</template>
@@ -0,0 +1,17 @@
<script setup>
// Thin wrapper to reuse TrrCreateForm with edit=true
import TrrCreateForm from './TrrCreateForm.vue';
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
types: { type: Array, default: () => [] },
banks: { type: Array, default: () => [] },
currencies: { type: Array, default: () => ['EUR'] },
id: { type: Number, default: 0 },
});
</script>
<template>
<TrrCreateForm :show="show" :person="person" :types="types" :banks="banks" :currencies="currencies" :edit="true" :id="id" @close="$emit('close')" />
</template>