production #1

Merged
sipo merged 45 commits from production into master 2026-01-27 18:02:44 +00:00
33 changed files with 1440 additions and 579 deletions
Showing only changes of commit cc4c07717e - Show all commits

View File

@ -62,7 +62,8 @@ public function index(Request $request)
$unassignedClients = $unassignedContracts->get() $unassignedClients = $unassignedContracts->get()
->pluck('clientCase.client') ->pluck('clientCase.client')
->filter() ->filter()
->unique('id'); ->unique('id')
->values();
$assignedContracts = Contract::query() $assignedContracts = Contract::query()
@ -98,7 +99,8 @@ public function index(Request $request)
$assignedClients = $assignedContracts->get() $assignedClients = $assignedContracts->get()
->pluck('clientCase.client') ->pluck('clientCase.client')
->filter() ->filter()
->unique('id'); ->unique('id')
->values();
$users = User::query()->orderBy('name')->get(['id', 'name']); $users = User::query()->orderBy('name')->get(['id', 'name']);

View File

@ -60,7 +60,7 @@
'features' => [ 'features' => [
// Features::termsAndPrivacyPolicy(), // Features::termsAndPrivacyPolicy(),
// Features::profilePhotos(), // Features::profilePhotos(),
Features::api(), // Features::api(),
// Features::teams(['invitations' => true]), // Features::teams(['invitations' => true]),
Features::accountDeletion(), Features::accountDeletion(),
], ],

View File

@ -1,118 +1,118 @@
<script setup> <script setup>
import { ref, reactive, nextTick } from 'vue'; import { ref, reactive, nextTick } from "vue";
import DialogModal from './DialogModal.vue'; import DialogModal from "./DialogModal.vue";
import InputError from './InputError.vue'; import InputError from "./InputError.vue";
import PrimaryButton from './PrimaryButton.vue'; import PrimaryButton from "./PrimaryButton.vue";
import SecondaryButton from './SecondaryButton.vue'; import SecondaryButton from "./SecondaryButton.vue";
import TextInput from './TextInput.vue'; import { Input } from "@/Components/ui/input";
const emit = defineEmits(['confirmed']); const emit = defineEmits(["confirmed"]);
defineProps({ defineProps({
title: { title: {
type: String, type: String,
default: 'Confirm Password', default: "Confirm Password",
}, },
content: { content: {
type: String, type: String,
default: 'For your security, please confirm your password to continue.', default: "For your security, please confirm your password to continue.",
}, },
button: { button: {
type: String, type: String,
default: 'Confirm', default: "Confirm",
}, },
}); });
const confirmingPassword = ref(false); const confirmingPassword = ref(false);
const form = reactive({ const form = reactive({
password: '', password: "",
error: '', error: "",
processing: false, processing: false,
}); });
const passwordInput = ref(null); const passwordInput = ref(null);
const startConfirmingPassword = () => { const startConfirmingPassword = () => {
axios.get(route('password.confirmation')).then(response => { axios.get(route("password.confirmation")).then((response) => {
if (response.data.confirmed) { if (response.data.confirmed) {
emit('confirmed'); emit("confirmed");
} else { } else {
confirmingPassword.value = true; confirmingPassword.value = true;
setTimeout(() => passwordInput.value.focus(), 250); setTimeout(() => passwordInput.value.focus(), 250);
} }
}); });
}; };
const confirmPassword = () => { const confirmPassword = () => {
form.processing = true; form.processing = true;
axios.post(route('password.confirm'), { axios
password: form.password, .post(route("password.confirm"), {
}).then(() => { password: form.password,
form.processing = false; })
.then(() => {
form.processing = false;
closeModal(); closeModal();
nextTick().then(() => emit('confirmed')); nextTick().then(() => emit("confirmed"));
})
}).catch(error => { .catch((error) => {
form.processing = false; form.processing = false;
form.error = error.response.data.errors.password[0]; form.error = error.response.data.errors.password[0];
passwordInput.value.focus(); passwordInput.value.focus();
}); });
}; };
const closeModal = () => { const closeModal = () => {
confirmingPassword.value = false; confirmingPassword.value = false;
form.password = ''; form.password = "";
form.error = ''; form.error = "";
}; };
</script> </script>
<template> <template>
<span> <span>
<span @click="startConfirmingPassword"> <span @click="startConfirmingPassword">
<slot /> <slot />
</span>
<DialogModal :show="confirmingPassword" @close="closeModal">
<template #title>
{{ title }}
</template>
<template #content>
{{ content }}
<div class="mt-4">
<TextInput
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-3/4"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="confirmPassword"
/>
<InputError :message="form.error" class="mt-2" />
</div>
</template>
<template #footer>
<SecondaryButton @click="closeModal">
Cancel
</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="confirmPassword"
>
{{ button }}
</PrimaryButton>
</template>
</DialogModal>
</span> </span>
<DialogModal :show="confirmingPassword" @close="closeModal">
<template #title>
{{ title }}
</template>
<template #content>
{{ content }}
<div class="mt-4">
<Input
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-3/4"
placeholder="Password"
autocomplete="current-password"
@keyup.enter="confirmPassword"
/>
<InputError :message="form.error" class="mt-2" />
</div>
</template>
<template #footer>
<SecondaryButton @click="closeModal"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="confirmPassword"
>
{{ button }}
</PrimaryButton>
</template>
</DialogModal>
</span>
</template> </template>

View File

@ -1,70 +0,0 @@
<script setup lang="ts">
import type { LucideIcon } from "lucide-vue-next";
import { ChevronRight } from "lucide-vue-next";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/Components/ui/collapsible";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/Components/ui/sidebar";
defineProps<{
items: {
title: string;
url: string;
icon?: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}>();
</script>
<template>
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<Collapsible
v-for="item in items"
:key="item.title"
as-child
:default-open="item.isActive"
class="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span>{{ item.title }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
<SidebarMenuSubButton as-child>
<a :href="subItem.url">
<span>{{ subItem.title }}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
</template>

View File

@ -7,12 +7,7 @@ import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue"; import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue"; import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@ -97,7 +92,7 @@ watch(
country: a.country || "", country: a.country || "",
post_code: a.post_code || a.postal_code || "", post_code: a.post_code || a.postal_code || "",
city: a.city || "", city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null), type_id: a.type_id ?? props.types?.[0]?.id ?? null,
description: a.description || "", description: a.description || "",
}); });
return; return;
@ -108,52 +103,51 @@ watch(
{ immediate: true } { immediate: true }
); );
watch(() => props.show, (val) => { watch(
if (val && props.edit && props.id) { () => props.show,
const a = props.person.addresses?.find((x) => x.id === props.id); (val) => {
if (a) { if (val && props.edit && props.id) {
form.setValues({ const a = props.person.addresses?.find((x) => x.id === props.id);
address: a.address || "", if (a) {
country: a.country || "", form.setValues({
post_code: a.post_code || a.postal_code || "", address: a.address || "",
city: a.city || "", country: a.country || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null), post_code: a.post_code || a.postal_code || "",
description: a.description || "", city: a.city || "",
}); type_id: a.type_id ?? props.types?.[0]?.id ?? null,
description: a.description || "",
});
}
} else if (val && !props.edit) {
resetForm();
} }
} else if (val && !props.edit) {
resetForm();
} }
}); );
const create = async () => { const create = async () => {
processing.value = true; processing.value = true;
const { values } = form; const { values } = form;
router.post( router.post(route("person.address.create", props.person), values, {
route("person.address.create", props.person), preserveScroll: true,
values, onSuccess: () => {
{ processing.value = false;
preserveScroll: true, close();
onSuccess: () => { resetForm();
processing.value = false; },
close(); onError: (errors) => {
resetForm(); Object.keys(errors).forEach((field) => {
}, const errorMessages = Array.isArray(errors[field])
onError: (errors) => { ? errors[field]
Object.keys(errors).forEach((field) => { : [errors[field]];
const errorMessages = Array.isArray(errors[field]) form.setFieldError(field, errorMessages[0]);
? errors[field] });
: [errors[field]]; processing.value = false;
form.setFieldError(field, errorMessages[0]); },
}); onFinish: () => {
processing.value = false; processing.value = false;
}, },
onFinish: () => { });
processing.value = false;
},
}
);
}; };
const update = async () => { const update = async () => {
@ -223,7 +217,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Naslov</FormLabel> <FormLabel>Naslov</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" /> <Input
type="text"
placeholder="Naslov"
autocomplete="street-address"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -233,7 +232,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Država</FormLabel> <FormLabel>Država</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" /> <Input
type="text"
placeholder="Država"
autocomplete="country"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -243,7 +247,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Poštna številka</FormLabel> <FormLabel>Poštna številka</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" /> <Input
type="text"
placeholder="Poštna številka"
autocomplete="postal-code"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -253,7 +262,22 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Mesto</FormLabel> <FormLabel>Mesto</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" /> <Input
type="text"
placeholder="Mesto"
autocomplete="address-level2"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue"; import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@ -85,7 +80,7 @@ const hydrate = () => {
country: a.country || "", country: a.country || "",
post_code: a.post_code || a.postal_code || "", post_code: a.post_code || a.postal_code || "",
city: a.city || "", city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null), type_id: a.type_id ?? props.types?.[0]?.id ?? null,
description: a.description || "", description: a.description || "",
}); });
return; return;
@ -94,10 +89,17 @@ const hydrate = () => {
resetForm(); resetForm();
}; };
watch(() => props.id, () => hydrate(), { immediate: true }); watch(
watch(() => props.show, (v) => { () => props.id,
if (v) hydrate(); () => hydrate(),
}); { immediate: true }
);
watch(
() => props.show,
(v) => {
if (v) hydrate();
}
);
const update = async () => { const update = async () => {
processing.value = true; processing.value = true;
@ -157,7 +159,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Naslov</FormLabel> <FormLabel>Naslov</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" /> <Input
type="text"
placeholder="Naslov"
autocomplete="street-address"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -167,7 +174,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Država</FormLabel> <FormLabel>Država</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" /> <Input
type="text"
placeholder="Država"
autocomplete="country"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -177,7 +189,12 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Poštna številka</FormLabel> <FormLabel>Poštna številka</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" /> <Input
type="text"
placeholder="Poštna številka"
autocomplete="postal-code"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -187,7 +204,22 @@ const onConfirm = () => {
<FormItem> <FormItem>
<FormLabel>Mesto</FormLabel> <FormLabel>Mesto</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" /> <Input
type="text"
placeholder="Mesto"
autocomplete="address-level2"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@ -24,9 +24,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
<template> <template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<Card class="p-2 gap-1" v-for="address in person.addresses" :key="address.id"> <Card class="p-2 gap-0" v-for="address in person.addresses" :key="address.id">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1">
<span <span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
> >
@ -61,13 +61,16 @@ const handleDelete = (id, label) => emit("delete", id, label);
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1"> <p class="font-medium text-gray-900 leading-relaxed p-1">
{{ {{
address.post_code && address.city address.post_code && address.city
? `${address.address}, ${address.post_code} ${address.city}` ? `${address.address}, ${address.post_code} ${address.city}`
: address.address : address.address
}} }}
</p> </p>
<p class="text-sm text-muted-foreground p-1" v-if="address.description">
{{ address.description }}
</p>
</Card> </Card>
<button <button
v-if="edit" v-if="edit"

View File

@ -27,9 +27,9 @@ const handleDelete = (id, label) => emit("delete", id, label);
<template> <template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getEmails(person).length"> <template v-if="getEmails(person).length">
<Card class="p-2 gap-1" v-for="(email, idx) in getEmails(person)" :key="idx"> <Card class="p-2 gap-0" v-for="(email, idx) in getEmails(person)" :key="idx">
<div class="flex items-center justify-between mb-2" v-if="edit"> <div class="flex items-center justify-between" v-if="edit">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1">
<span <span
v-if="email?.label" 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" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
@ -69,7 +69,7 @@ const handleDelete = (id, label) => emit("delete", id, label);
</div> </div>
</div> </div>
<div class="p-1"> <div class="p-1">
<p class="text-sm font-medium text-gray-900 leading-relaxed"> <p class="font-medium text-gray-900 leading-relaxed">
{{ email?.value || email?.email || email?.address || "-" }} {{ email?.value || email?.email || email?.address || "-" }}
</p> </p>
<p <p

View File

@ -30,9 +30,9 @@ const handleSms = (phone) => emit("sms", phone);
<template> <template>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<template v-if="getPhones(person).length"> <template v-if="getPhones(person).length">
<Card class="p-2 gap-1" v-for="phone in getPhones(person)" :key="phone.id"> <Card class="p-2 gap-0" v-for="phone in getPhones(person)" :key="phone.id">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1">
<span <span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
> >
@ -79,9 +79,12 @@ const handleSms = (phone) => emit("sms", phone);
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
<p class="text-sm font-medium text-gray-900 leading-relaxed p-1"> <p class="font-medium leading-relaxed p-1">
{{ phone.nu }} {{ phone.nu }}
</p> </p>
<p class="text-sm text-muted-foreground p-1" v-if="phone.description">
{{ phone.description }}
</p>
</Card> </Card>
</template> </template>
<button <button

View File

@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue"; import CreateDialog from "../Dialogs/CreateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@ -101,29 +96,25 @@ const create = async () => {
processing.value = true; processing.value = true;
const { values } = form; const { values } = form;
router.post( router.post(route("person.phone.create", props.person), values, {
route("person.phone.create", props.person), preserveScroll: true,
values, onSuccess: () => {
{ close();
preserveScroll: true, resetForm();
onSuccess: () => { },
close(); onError: (errors) => {
resetForm(); Object.keys(errors).forEach((field) => {
}, const errorMessages = Array.isArray(errors[field])
onError: (errors) => { ? errors[field]
Object.keys(errors).forEach((field) => { : [errors[field]];
const errorMessages = Array.isArray(errors[field]) form.setFieldError(field, errorMessages[0]);
? errors[field] });
: [errors[field]]; processing.value = false;
form.setFieldError(field, errorMessages[0]); },
}); onFinish: () => {
processing.value = false; processing.value = false;
}, },
onFinish: () => { });
processing.value = false;
},
}
);
}; };
const onSubmit = form.handleSubmit(() => { const onSubmit = form.handleSubmit(() => {
@ -150,7 +141,12 @@ const onSubmit = form.handleSubmit(() => {
<FormItem> <FormItem>
<FormLabel>Številka</FormLabel> <FormLabel>Številka</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" /> <Input
type="text"
placeholder="Številka telefona"
autocomplete="tel"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -166,7 +162,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value"> <SelectItem
v-for="option in countryOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@ -204,7 +204,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value"> <SelectItem
v-for="option in phoneTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@ -213,6 +217,16 @@ const onSubmit = form.handleSubmit(() => {
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<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>

View File

@ -6,12 +6,7 @@ import * as z from "zod";
import { router } from "@inertiajs/vue3"; import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue"; import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue"; import SectionTitle from "../SectionTitle.vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { import {
Select, Select,
@ -108,7 +103,7 @@ function hydrateFromProps() {
form.setValues({ form.setValues({
nu: p.nu || "", nu: p.nu || "",
country_code: p.country_code ?? 386, country_code: p.country_code ?? 386,
type_id: p.type_id ?? (props.types?.[0]?.id ?? null), type_id: p.type_id ?? props.types?.[0]?.id ?? null,
description: p.description || "", description: p.description || "",
validated: !!p.validated, validated: !!p.validated,
phone_type: p.phone_type ?? null, phone_type: p.phone_type ?? null,
@ -119,8 +114,17 @@ function hydrateFromProps() {
resetForm(); resetForm();
} }
watch(() => props.id, () => hydrateFromProps(), { immediate: true }); watch(
watch(() => props.show, (val) => { if (val) hydrateFromProps(); }); () => props.id,
() => hydrateFromProps(),
{ immediate: true }
);
watch(
() => props.show,
(val) => {
if (val) hydrateFromProps();
}
);
const update = async () => { const update = async () => {
processing.value = true; processing.value = true;
@ -175,7 +179,12 @@ const onSubmit = form.handleSubmit(() => {
<FormItem> <FormItem>
<FormLabel>Številka</FormLabel> <FormLabel>Številka</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" /> <Input
type="text"
placeholder="Številka telefona"
autocomplete="tel"
v-bind="componentField"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -191,7 +200,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value"> <SelectItem
v-for="option in countryOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@ -229,7 +242,11 @@ const onSubmit = form.handleSubmit(() => {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value"> <SelectItem
v-for="option in phoneTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@ -238,6 +255,16 @@ const onSubmit = form.handleSubmit(() => {
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Input type="text" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<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>

View File

@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
import { fieldVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
});
</script>
<template>
<div
role="group"
data-slot="field"
:data-orientation="orientation"
:class="cn(fieldVariants({ orientation }), props.class)"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-content"
:class="
cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,23 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="field-description"
:class="
cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
props.class,
)
"
>
<slot />
</p>
</template>

View File

@ -0,0 +1,43 @@
<script setup>
import { computed } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
errors: { type: Array, required: false },
});
const content = computed(() => {
if (!props.errors || props.errors.length === 0) return null;
if (props.errors.length === 1 && props.errors[0]?.message) {
return props.errors[0].message;
}
return props.errors.some((e) => e?.message) ? props.errors : null;
});
</script>
<template>
<div
v-if="$slots.default || content"
role="alert"
data-slot="field-error"
:class="cn('text-destructive text-sm font-normal', props.class)"
>
<slot v-if="$slots.default" />
<template v-else-if="typeof content === 'string'">
{{ content }}
</template>
<ul
v-else-if="Array.isArray(content)"
class="ml-4 flex list-disc flex-col gap-1"
>
<li v-for="(error, index) in content" :key="index">
{{ error?.message }}
</li>
</ul>
</div>
</template>

View File

@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-group"
:class="
cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,24 @@
<script setup>
import { cn } from "@/lib/utils";
import { Label } from '@/Components/ui/label';
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<Label
data-slot="field-label"
:class="
cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',
'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@ -0,0 +1,25 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
variant: { type: String, required: false },
});
</script>
<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="
cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
props.class,
)
"
>
<slot />
</legend>
</template>

View File

@ -0,0 +1,30 @@
<script setup>
import { cn } from "@/lib/utils";
import { Separator } from '@/Components/ui/separator';
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-separator"
:data-content="!!$slots.default"
:class="
cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
props.class,
)
"
>
<Separator class="absolute inset-0 top-1/2" />
<span
v-if="$slots.default"
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
<slot />
</span>
</div>
</template>

View File

@ -0,0 +1,22 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<fieldset
data-slot="field-set"
:class="
cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
props.class,
)
"
>
<slot />
</fieldset>
</template>

View File

@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="field-label"
:class="
cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,36 @@
import { cva } from "class-variance-authority";
export const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
export { default as Field } from "./Field.vue";
export { default as FieldContent } from "./FieldContent.vue";
export { default as FieldDescription } from "./FieldDescription.vue";
export { default as FieldError } from "./FieldError.vue";
export { default as FieldGroup } from "./FieldGroup.vue";
export { default as FieldLabel } from "./FieldLabel.vue";
export { default as FieldLegend } from "./FieldLegend.vue";
export { default as FieldSeparator } from "./FieldSeparator.vue";
export { default as FieldSet } from "./FieldSet.vue";
export { default as FieldTitle } from "./FieldTitle.vue";

View File

@ -36,6 +36,7 @@ const props = defineProps({
reference: { type: null, required: false }, reference: { type: null, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
}); });
const emits = defineEmits([ const emits = defineEmits([

View File

@ -1,8 +1,18 @@
<script setup> <script setup>
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import { Card, CardContent } from "@/Components/ui/card";
import { Separator } from "@/Components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import axios from "axios"; import axios from "axios";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { SearchIcon } from "@/Utilities/Icons"; import { SearchIcon, XIcon } from "lucide-vue-next";
import { onMounted, onUnmounted, ref, watch } from "vue"; import { onMounted, onUnmounted, ref, watch } from "vue";
import { Link } from "@inertiajs/vue3"; import { Link } from "@inertiajs/vue3";
@ -55,203 +65,169 @@ onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown)); onUnmounted(() => window.removeEventListener("keydown", onKeydown));
</script> </script>
<template> <template>
<teleport to="body"> <Dialog :open="isOpen" @update:open="(v) => (isOpen = v)">
<transition name="fade"> <DialogContent class="max-w-3xl p-0 gap-0 [&>button]:hidden">
<div v-if="isOpen" class="fixed inset-0 z-50"> <div class="p-4 border-b" ref="inputWrap">
<div <div class="relative">
class="absolute inset-0 bg-gradient-to-br from-slate-900/60 to-slate-800/60 backdrop-blur-sm" <SearchIcon
@click="isOpen = false" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
></div> />
<div <Input
class="absolute inset-0 flex items-start justify-center p-4 pt-20 sm:pt-28" v-model="query"
@click.self="isOpen = false" placeholder="Išči po naročnikih ali primerih (ESC za zapiranje)"
> class="w-full pl-10 pr-16"
<div />
class="w-full max-w-3xl rounded-2xl border border-white/10 bg-white/80 backdrop-blur-xl shadow-2xl ring-1 ring-black/5 overflow-hidden" <button
role="dialog" v-if="query"
aria-modal="true" @click="query = ''"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-accent"
> >
<XIcon class="h-4 w-4 text-muted-foreground" />
</button>
</div>
</div>
<div class="max-h-[65vh] overflow-y-auto">
<div
v-if="!query"
class="p-8 text-sm text-muted-foreground text-center space-y-2"
>
<p>Začni tipkati za iskanje.</p>
<p class="text-xs">
Namig: uporabi <Badge variant="secondary" class="font-mono">Ctrl</Badge> +
<Badge variant="secondary" class="font-mono">K</Badge>
</p>
</div>
<div v-else class="space-y-4 p-4">
<!-- Clients Results -->
<div v-if="result.clients.length">
<div <div
class="p-4 border-b border-slate-200/60" class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
ref="inputWrap"
> >
<div class="relative"> <span>Naročniki</span>
<div class="relative"> <Badge variant="secondary">{{ result.clients.length }}</Badge>
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
<SearchIcon />
</div>
<Input
v-model="query"
placeholder="Išči po naročnikih ali primerih (Ctrl+K za zapiranje)"
class="w-full pl-10 pr-16 rounded-xl"
/>
<button
v-if="query"
@click="query = ''"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-700"
>
ESC
</button>
</div>
</div>
</div> </div>
<div <div class="space-y-1">
class="max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-300" <Link
> v-for="client in result.clients"
<div :key="client.client_uuid"
v-if="!query" :href="route('client.show', { uuid: client.client_uuid })"
class="p-8 text-sm text-slate-500 text-center space-y-2" class="group flex items-center gap-3 w-full rounded-lg px-3 py-2 text-sm hover:bg-accent transition"
@click="isOpen = false"
> >
<p>Začni tipkati za iskanje.</p> <Badge
<p class="text-xs"> variant="outline"
Namig: uporabi class="shrink-0 w-6 h-6 flex items-center justify-center"
<kbd >C</Badge
class="px-1.5 py-0.5 bg-slate-100 rounded font-mono text-[10px]"
>Ctrl</kbd
>
+
<kbd
class="px-1.5 py-0.5 bg-slate-100 rounded font-mono text-[10px]"
>K</kbd
>
</p>
</div>
<div v-else class="divide-y divide-slate-200/70">
<div v-if="result.clients.length" class="py-3">
<div
class="flex items-center justify-between px-5 pb-1 text-[11px] font-semibold tracking-wide uppercase text-slate-500"
>
<span>Naročniki</span>
<span
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
>{{ result.clients.length }}</span
>
</div>
<ul role="list" class="px-2 space-y-1">
<li v-for="client in result.clients" :key="client.client_uuid">
<Link
:href="route('client.show', { uuid: client.client_uuid })"
class="group flex items-center gap-3 w-full rounded-lg px-3 py-2 text-sm hover:bg-indigo-50/70 transition"
@click="isOpen = false"
>
<span
class="shrink-0 w-6 h-6 rounded bg-indigo-100 text-indigo-600 flex items-center justify-center text-[11px] font-semibold group-hover:bg-indigo-200"
>C</span
>
<span
class="text-slate-700 group-hover:text-slate-900"
>{{ client.full_name }}</span
>
</Link>
</li>
</ul>
</div>
<div v-if="result.client_cases.length" class="py-3">
<div
class="flex items-center justify-between px-5 pb-1 text-[11px] font-semibold tracking-wide uppercase text-slate-500"
>
<span>Primeri</span>
<span
class="rounded bg-slate-100 text-slate-600 px-2 py-0.5 text-[10px]"
>{{ result.client_cases.length }}</span
>
</div>
<ul role="list" class="px-2 space-y-1">
<li
v-for="clientcase in result.client_cases"
:key="clientcase.case_uuid"
class="rounded-xl border border-slate-200/70 bg-white/70 px-4 py-3 shadow-sm hover:shadow-md transition flex flex-col gap-1"
>
<div class="flex items-center gap-2">
<Link
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
})
"
class="text-left font-medium hover:underline leading-tight text-slate-800"
@click="isOpen = false"
>
{{ clientcase.full_name }}
</Link>
<template v-if="clientcase.contract_reference">
<span
class="font-mono text-[11px] tracking-tight text-indigo-600 bg-indigo-50 border border-indigo-200 rounded px-1.5 py-0.5 whitespace-nowrap shadow-sm"
>
{{ clientcase.contract_reference }}
</span>
</template>
</div>
<div
v-if="
clientcase.contract_segments &&
clientcase.contract_segments.length
"
class="flex flex-wrap gap-1 mt-1"
>
<Link
v-for="seg in clientcase.contract_segments"
:key="seg.id || seg.name || seg"
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
}) +
'?segment=' +
(seg.id || seg)
"
class="group/seg text-[10px] uppercase tracking-wide bg-gradient-to-br from-purple-50 to-purple-100 text-purple-700 border border-purple-200 px-1.5 py-0.5 rounded hover:from-purple-100 hover:to-purple-200 hover:border-purple-300 transition"
@click="isOpen = false"
>
{{ seg.name || seg }}
</Link>
</div>
<div
v-else-if="
clientcase.case_segments && clientcase.case_segments.length
"
class="flex flex-wrap gap-1 mt-1"
>
<Link
v-for="seg in clientcase.case_segments"
:key="seg.id || seg.name"
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
}) +
'?segment=' +
(seg.id || seg)
"
class="text-[10px] uppercase tracking-wide bg-slate-100 text-slate-600 border border-slate-200 px-1.5 py-0.5 rounded hover:bg-slate-200 hover:text-slate-700 transition"
@click="isOpen = false"
>
{{ seg.name }}
</Link>
</div>
</li>
</ul>
</div>
<div
v-if="!result.clients.length && !result.client_cases.length"
class="p-8 text-center text-sm text-slate-500"
> >
Ni rezultatov. <span class="font-medium">{{ client.full_name }}</span>
</div> </Link>
</div>
</div> </div>
</div> </div>
<Separator v-if="result.clients.length && result.client_cases.length" />
<!-- Client Cases Results -->
<div v-if="result.client_cases.length">
<div
class="flex items-center justify-between pb-2 text-xs font-semibold tracking-wide uppercase text-muted-foreground"
>
<span>Primeri</span>
<Badge variant="secondary">{{ result.client_cases.length }}</Badge>
</div>
<div class="space-y-2">
<Card
v-for="clientcase in result.client_cases"
:key="clientcase.case_uuid"
class="hover:shadow-md transition p-0"
>
<CardContent class="p-3 space-y-2">
<div class="space-y-1">
<Link
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
})
"
class="text-sm font-medium hover:underline block"
@click="isOpen = false"
>
{{ clientcase.full_name }}
</Link>
<div
v-if="clientcase.client_full_name"
class="text-xs text-muted-foreground"
>
Naročnik: {{ clientcase.client_full_name }}
</div>
</div>
<div
v-if="clientcase.contract_reference"
class="flex items-center gap-1"
>
<Badge variant="outline" class="font-mono text-xs">
{{ clientcase.contract_reference }}
</Badge>
</div>
<div
v-if="
clientcase.contract_segments && clientcase.contract_segments.length
"
class="flex flex-wrap gap-1"
>
<Link
v-for="seg in clientcase.contract_segments"
:key="seg.id || seg.name || seg"
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
}) +
'?segment=' +
(seg.id || seg)
"
@click="isOpen = false"
>
<Badge variant="secondary" class="text-xs uppercase">
{{ seg.name || seg }}
</Badge>
</Link>
</div>
<div
v-else-if="
clientcase.case_segments && clientcase.case_segments.length
"
class="flex flex-wrap gap-1"
>
<Link
v-for="seg in clientcase.case_segments"
:key="seg.id || seg.name"
:href="
route('clientCase.show', {
client_case: clientcase.case_uuid,
}) +
'?segment=' +
(seg.id || seg)
"
@click="isOpen = false"
>
<Badge variant="outline" class="text-xs uppercase">
{{ seg.name }}
</Badge>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
<!-- No Results -->
<div
v-if="!result.clients.length && !result.client_cases.length"
class="p-8 text-center text-sm text-muted-foreground"
>
Ni rezultatov.
</div>
</div> </div>
</div> </div>
</transition> </DialogContent>
</teleport> </Dialog>
</template> </template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,229 @@
<script setup>
import { ref, watch } from "vue";
import { router } from "@inertiajs/vue3";
import DialogModal from "@/Components/DialogModal.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { ScrollArea } from "@/Components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Plus, Trash2 } from "lucide-vue-next";
const props = defineProps({
show: { type: Boolean, default: false },
client_case: { type: Object, required: true },
contract: { type: Object, default: null },
});
const emit = defineEmits(["close"]);
const processing = ref(false);
const metaEntries = ref([]);
// Extract meta entries from contract
function extractMetaEntries(contract) {
if (!contract?.meta) return [];
const results = [];
const visit = (node, keyName) => {
if (node === null || node === undefined) return;
if (Array.isArray(node)) {
node.forEach((el) => visit(el));
return;
}
if (typeof node === "object") {
const hasValue = Object.prototype.hasOwnProperty.call(node, "value");
const hasTitle = Object.prototype.hasOwnProperty.call(node, "title");
if (hasValue || hasTitle) {
const title = (node.title || keyName || "").toString().trim() || keyName || "";
results.push({
title,
value: node.value ?? "",
type: node.type || "string",
});
return;
}
for (const [k, v] of Object.entries(node)) {
visit(v, k);
}
return;
}
if (keyName) {
results.push({ title: keyName, value: node ?? "", type: "string" });
}
};
visit(contract.meta, undefined);
return results;
}
// Initialize meta entries when dialog opens
watch(
() => props.show,
(newVal) => {
if (newVal && props.contract) {
const entries = extractMetaEntries(props.contract);
metaEntries.value =
entries.length > 0 ? entries : [{ title: "", value: "", type: "string" }];
}
}
);
function addEntry() {
metaEntries.value.push({ title: "", value: "", type: "string" });
}
function removeEntry(index) {
metaEntries.value.splice(index, 1);
if (metaEntries.value.length === 0) {
metaEntries.value.push({ title: "", value: "", type: "string" });
}
}
function close() {
emit("close");
}
function submit() {
if (!props.contract?.uuid || processing.value) return;
// Filter out empty entries and build meta object
const validEntries = metaEntries.value.filter((e) => e.title && e.title.trim() !== "");
const meta = {};
validEntries.forEach((entry) => {
meta[entry.title] = {
title: entry.title,
value: entry.value,
type: entry.type,
};
});
processing.value = true;
router.patch(
route("clientCase.contract.patchMeta", {
client_case: props.client_case.uuid,
uuid: props.contract.uuid,
}),
{ meta },
{
preserveScroll: true,
only: ["contracts"],
onSuccess: () => {
close();
processing.value = false;
},
onError: () => {
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
}
</script>
<template>
<DialogModal :show="show" max-width="3xl" @close="close">
<template #title>
<h3 class="text-lg font-semibold leading-6 text-foreground">Uredi Meta podatke</h3>
</template>
<template #description>
Posodobi meta podatke za pogodbo {{ contract?.reference }}
</template>
<template #content>
<form id="meta-edit-form" @submit.prevent="submit" class="space-y-4">
<ScrollArea class="h-[60vh]">
<div class="space-y-3 pr-4">
<div
v-for="(entry, index) in metaEntries"
:key="index"
class="flex items-start gap-2 p-3 border rounded-lg bg-muted/20"
>
<div class="flex-1 space-y-3">
<div>
<Label :for="`meta-title-${index}`">Naziv</Label>
<Input
:id="`meta-title-${index}`"
v-model="entry.title"
placeholder="Vnesi naziv..."
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<Label :for="`meta-type-${index}`">Tip</Label>
<Select v-model="entry.type">
<SelectTrigger :id="`meta-type-${index}`" class="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">Tekst</SelectItem>
<SelectItem value="number">Številka</SelectItem>
<SelectItem value="date">Datum</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label :for="`meta-value-${index}`">Vrednost</Label>
<Input
:id="`meta-value-${index}`"
v-model="entry.value"
:type="
entry.type === 'date'
? 'date'
: entry.type === 'number'
? 'number'
: 'text'
"
:step="entry.type === 'number' ? '0.01' : undefined"
placeholder="Vnesi vrednost..."
class="mt-1"
/>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
@click="removeEntry(index)"
:disabled="metaEntries.length === 1"
class="mt-6"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</ScrollArea>
<Button type="button" variant="outline" @click="addEntry" class="w-full">
<Plus class="h-4 w-4 mr-2" />
Dodaj vnos
</Button>
</form>
</template>
<template #footer>
<div class="flex flex-row gap-2">
<Button type="button" variant="ghost" @click="close" :disabled="processing">
Prekliči
</Button>
<Button type="submit" form="meta-edit-form" :disabled="processing">
{{ processing ? "Shranjujem..." : "Shrani" }}
</Button>
</div>
</template>
</DialogModal>
</template>

View File

@ -15,6 +15,7 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
import CaseObjectsDialog from "./CaseObjectsDialog.vue"; import CaseObjectsDialog from "./CaseObjectsDialog.vue";
import PaymentDialog from "./PaymentDialog.vue"; import PaymentDialog from "./PaymentDialog.vue";
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue"; import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
import ContractMetaEditDialog from "./ContractMetaEditDialog.vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue"; import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue"; import ConfirmationDialog from "@/Components/Dialogs/ConfirmationDialog.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@ -33,6 +34,16 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import EmptyState from "@/Components/EmptyState.vue"; import EmptyState from "@/Components/EmptyState.vue";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Label } from "@/Components/ui/label";
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({ const props = defineProps({
client: { type: Object, default: null }, client: { type: Object, default: null },
@ -433,6 +444,19 @@ const closePaymentsDialog = () => {
selectedContract.value = null; selectedContract.value = null;
}; };
// Meta edit dialog
const showMetaEditDialog = ref(false);
const openMetaEditDialog = (c) => {
selectedContract.value = c;
showMetaEditDialog.value = true;
};
const closeMetaEditDialog = () => {
showMetaEditDialog.value = false;
selectedContract.value = null;
};
// Columns configuration // Columns configuration
const columns = computed(() => [ const columns = computed(() => [
{ key: "reference", label: "Ref.", sortable: false, align: "center" }, { key: "reference", label: "Ref.", sortable: false, align: "center" },
@ -638,6 +662,19 @@ const availableSegmentsCount = computed(() => {
<div class="text-gray-500">Ni meta podatkov.</div> <div class="text-gray-500">Ni meta podatkov.</div>
</template> </template>
</div> </div>
<div v-if="edit && row.active" class="border-t border-gray-200 mt-2 pt-2">
<button
type="button"
@click="openMetaEditDialog(row)"
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 rounded transition-colors"
>
<FontAwesomeIcon
:icon="faPenToSquare"
class="h-3.5 w-3.5 text-gray-600"
/>
<span>Uredi meta podatke</span>
</button>
</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -901,6 +938,13 @@ const availableSegmentsCount = computed(() => {
:edit="edit" :edit="edit"
/> />
<ContractMetaEditDialog
:show="showMetaEditDialog"
:client_case="client_case"
:contract="selectedContract"
@close="closeMetaEditDialog"
/>
<!-- Generate Document Dialog --> <!-- Generate Document Dialog -->
<CreateDialog <CreateDialog
:show="showGenerateDialog" :show="showGenerateDialog"
@ -913,18 +957,18 @@ const availableSegmentsCount = computed(() => {
@confirm="submitGenerate" @confirm="submitGenerate"
> >
<div class="space-y-4"> <div class="space-y-4">
<div> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Predloga</label> <Label>Predloga</Label>
<select <Select v-model="selectedTemplateSlug" @update:model-value="onTemplateChange">
v-model="selectedTemplateSlug" <SelectTrigger>
@change="onTemplateChange" <SelectValue placeholder="Izberi predlogo..." />
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" </SelectTrigger>
> <SelectContent>
<option :value="null">Izberi predlogo...</option> <SelectItem v-for="t in templates" :key="t.slug" :value="t.slug">
<option v-for="t in templates" :key="t.slug" :value="t.slug"> {{ t.name }} (v{{ t.version }})
{{ t.name }} (v{{ t.version }}) </SelectItem>
</option> </SelectContent>
</select> </Select>
</div> </div>
<!-- Custom inputs --> <!-- Custom inputs -->
@ -932,14 +976,30 @@ const availableSegmentsCount = computed(() => {
<div class="border-t border-gray-200 pt-4"> <div class="border-t border-gray-200 pt-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3> <h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3>
<div class="space-y-3"> <div class="space-y-3">
<div v-for="token in customTokenList" :key="token"> <div v-for="token in customTokenList" :key="token" class="space-y-2">
<label class="block text-sm font-medium text-gray-700"> <Label>
{{ token.replace(/^custom\./, "") }} {{ token.replace(/^custom\./, "") }}
</label> </Label>
<input <Textarea
v-if="templateCustomTypes[token.replace(/^custom\./, '')] === 'text'"
v-model="customInputs[token.replace(/^custom\./, '')]" v-model="customInputs[token.replace(/^custom\./, '')]"
type="text" rows="3"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" />
<Input
v-else
v-model="customInputs[token.replace(/^custom\./, '')]"
:type="
templateCustomTypes[token.replace(/^custom\./, '')] === 'date'
? 'date'
: templateCustomTypes[token.replace(/^custom\./, '')] === 'number'
? 'number'
: 'text'
"
:step="
templateCustomTypes[token.replace(/^custom\./, '')] === 'number'
? '0.01'
: undefined
"
/> />
</div> </div>
</div> </div>
@ -948,26 +1008,30 @@ const availableSegmentsCount = computed(() => {
<!-- Address overrides --> <!-- Address overrides -->
<div class="border-t border-gray-200 pt-4 space-y-3"> <div class="border-t border-gray-200 pt-4 space-y-3">
<h3 class="text-sm font-medium text-gray-700">Naslovi</h3> <h3 class="text-sm font-medium text-gray-700 mb-2">Naslovi</h3>
<div> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Naslov stranke</label> <Label>Naslov stranke</Label>
<select <Select v-model="clientAddressSource">
v-model="clientAddressSource" <SelectTrigger>
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" <SelectValue />
> </SelectTrigger>
<option value="client">Stranka</option> <SelectContent>
<option value="case_person">Oseba primera</option> <SelectItem value="client">Stranka</SelectItem>
</select> <SelectItem value="case_person">Oseba primera</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Naslov osebe</label> <Label>Naslov osebe</Label>
<select <Select v-model="personAddressSource">
v-model="personAddressSource" <SelectTrigger>
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500" <SelectValue />
> </SelectTrigger>
<option value="case_person">Oseba primera</option> <SelectContent>
<option value="client">Stranka</option> <SelectItem value="case_person">Oseba primera</SelectItem>
</select> <SelectItem value="client">Stranka</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>

View File

@ -210,14 +210,6 @@ const closeDrawer = () => {
drawerAddActivity.value = false; drawerAddActivity.value = false;
}; };
const showClientDetails = () => {
clientDetails.value = false;
};
const hideClietnDetails = () => {
clientDetails.value = true;
};
// Attach segment to case // Attach segment to case
const showAttachSegment = ref(false); const showAttachSegment = ref(false);
const openAttachSegment = () => { const openAttachSegment = () => {

View File

@ -24,18 +24,31 @@ import DateRangePicker from "@/Components/DateRangePicker.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ButtonGroup } from "@/Components/ui/button-group"; import { ButtonGroup } from "@/Components/ui/button-group";
import AppPopover from "@/Components/app/ui/AppPopover.vue"; import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { Filter, LinkIcon, FileDown } from "lucide-vue-next"; import { Filter, LinkIcon, FileDown, LayoutIcon } from "lucide-vue-next";
import { Card } from "@/Components/ui/card"; import { Card } from "@/Components/ui/card";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { hasPermission } from "@/Services/permissions"; import { hasPermission } from "@/Services/permissions";
import InputLabel from "@/Components/InputLabel.vue"; import InputLabel from "@/Components/InputLabel.vue";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { toNumber } from "lodash";
import { FormControl, FormField, FormFieldArray, FormLabel } from "@/Components/ui/form";
import { Field, FieldLabel } from "@/Components/ui/field";
import { toTypedSchema } from "@vee-validate/zod";
import { z } from "zod";
import FormChangeSegment from "./Partials/FormChangeSegment.vue";
const props = defineProps({ const props = defineProps({
client: Object, client: Object,
contracts: Object, contracts: Object,
filters: Object, filters: Object,
segments: Object, segments: Array,
types: Object, types: Object,
}); });
@ -59,10 +72,20 @@ const selectedSegments = ref(
: [] : []
); );
const filterPopoverOpen = ref(false); const filterPopoverOpen = ref(false);
const selectedContracts = ref([]);
const changeSegmentDialogOpen = ref(false);
const contractTable = ref(null);
const exportDialogOpen = ref(false); const exportDialogOpen = ref(false);
const exportScope = ref("current"); const exportScope = ref("current");
const exportColumns = ref(["reference", "customer", "address", "start", "segment", "balance"]); const exportColumns = ref([
"reference",
"customer",
"address",
"start",
"segment",
"balance",
]);
const exportError = ref(""); const exportError = ref("");
const isExporting = ref(false); const isExporting = ref(false);
@ -85,6 +108,12 @@ const allColumnsSelected = computed(
const exportDisabled = computed( const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value () => exportColumns.value.length === 0 || isExporting.value
); );
const segmentSelectItems = computed(() =>
props.segments.map((val, i) => ({
label: val.name,
value: val.id,
}))
);
function applyDateFilter() { function applyDateFilter() {
filterPopoverOpen.value = false; filterPopoverOpen.value = false;
@ -288,6 +317,24 @@ function extractFilenameFromHeaders(headers) {
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i); const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
return asciiMatch?.[1] || null; return asciiMatch?.[1] || null;
} }
function handleSelectionChange(selectedKeys) {
selectedContracts.value = selectedKeys.map((val, i) => {
const num = toNumber(val);
return props.contracts.data[num].uuid;
});
}
function openDialogChangeSegment() {
changeSegmentDialogOpen.value = true;
}
function clearContractTableSelected() {
if (contractTable.value) {
contractTable.value.clearSelection();
}
}
</script> </script>
<template> <template>
@ -357,6 +404,7 @@ function extractFilenameFromHeaders(headers) {
</Link> </Link>
</div> </div>
<DataTable <DataTable
ref="contractTable"
:columns="[ :columns="[
{ key: 'reference', label: 'Referenca', sortable: false }, { key: 'reference', label: 'Referenca', sortable: false },
{ key: 'customer', label: 'Stranka', sortable: false }, { key: 'customer', label: 'Stranka', sortable: false },
@ -380,11 +428,13 @@ function extractFilenameFromHeaders(headers) {
row-key="uuid" row-key="uuid"
:only-props="['contracts']" :only-props="['contracts']"
:page-size-options="[10, 15, 25, 50, 100]" :page-size-options="[10, 15, 25, 50, 100]"
:enable-row-selection="true"
@selection:change="handleSelectionChange"
page-param-name="contracts_page" page-param-name="contracts_page"
per-page-param-name="contracts_per_page" per-page-param-name="contracts_per_page"
:show-toolbar="true" :show-toolbar="true"
> >
<template #toolbar-filters> <template #toolbar-filters="{ table }">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<AppPopover <AppPopover
v-model:open="filterPopoverOpen" v-model:open="filterPopoverOpen"
@ -481,6 +531,32 @@ function extractFilenameFromHeaders(headers) {
<FileDown class="h-4 w-4" /> <FileDown class="h-4 w-4" />
Izvozi v Excel Izvozi v Excel
</Button> </Button>
<DropdownMenu v-if="table.getSelectedRowModel().rows.length > 0">
<DropdownMenuTrigger as-child>
<Button class="gap-2 px-3" variant="outline">
<Badge
class="h-5 min-w-5 rounded-full font-mono tabular-nums text-accent"
variant="destructive"
>
{{ table.getSelectedRowModel().rows.length }}
</Badge>
Akcija
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem @click="openDialogChangeSegment">
<LayoutIcon />
Spremeni segment
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
@click="clearContractTableSelected"
v-if="table.getSelectedRowModel().rows.length > 0"
>
Odznači izbrane
</Button>
</div> </div>
</template> </template>
<template #cell-reference="{ row }"> <template #cell-reference="{ row }">
@ -519,7 +595,7 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</div> </div>
</div> </div>
<!-- Excel export dialog -->
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog"> <DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
<template #title> <template #title>
<div class="space-y-1"> <div class="space-y-1">
@ -626,5 +702,15 @@ function extractFilenameFromHeaders(headers) {
</div> </div>
</template> </template>
</DialogModal> </DialogModal>
<!-- Change segment selected contracts dialog -->
<FormChangeSegment
:show="changeSegmentDialogOpen"
@close="changeSegmentDialogOpen = false"
:segments="segmentSelectItems"
:contracts="selectedContracts"
:clear-selected-rows="clearContractTableSelected"
/>
</AppLayout> </AppLayout>
</template> </template>

View File

@ -0,0 +1,155 @@
<script setup>
import DialogModal from "@/Components/DialogModal.vue";
import { Button } from "@/Components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldLabel,
} from "@/Components/ui/field";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { toTypedSchema } from "@vee-validate/zod";
import { useForm, Field as VeeField } from "vee-validate";
import { router } from "@inertiajs/vue3";
import { onMounted, ref } from "vue";
import z from "zod";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
segments: { type: Array, default: [] },
contracts: { type: Array, default: [] },
clearSelectedRows: { type: Function, default: () => console.log("test") },
});
const emit = defineEmits(["close"]);
const close = () => {
emit("close");
};
const processing = ref(false);
// vee-validate Form setup
const formSchema = toTypedSchema(
z.object({
segment_id: z
.number()
.refine((val) => props.segments.find((item) => item.value == val) !== undefined, {
message: "Izbran segment ne obstaja v zbirki segmentov",
}),
})
);
const { handleSubmit, resetForm, errors } = useForm({
validationSchema: formSchema,
});
const onSubmit = handleSubmit((data) => {
processing.value = true;
router.patch(
route("contracts.segment"),
{
...data,
contracts: props.contracts,
},
{
onSuccess: () => {
router.reload({ only: ["contracts"] });
close();
resetForm();
props.clearSelectedRows();
processing.value = false;
},
onError: (e) => {
errors = e;
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
});
onMounted(() => {
console.log(props.segments);
});
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
<h3 class="text-lg font-semibold leading-6 text-foreground">
Spremeni segment pogodbam
</h3>
</template>
<template #content>
<form id="segment-change-form" @submit.prevent="onSubmit">
<VeeField v-slot="{ field, errors }" name="segment_id">
<Field orientation="responsive" :data-invalid="!!errors.length">
<FieldContent>
<FieldLabel for="segment">Segment</FieldLabel>
<FieldDescription>Izberi segment za preusmeritev</FieldDescription>
<FieldError v-if="errors.length" :errors="errors" />
</FieldContent>
<Select
:model-value="field.value"
@update:model-value="field.onChange"
@blur="field.onBlur"
>
<SelectTrigger id="segment_id" :aria-invalid="!!errors.length">
<SelectValue placeholder="Izberi segment..."></SelectValue>
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value="auto"> Auto </SelectItem>
<SelectItem
v-for="segment in segments"
:key="segment.label"
:value="segment.value"
>
{{ segment.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
</VeeField>
</form>
</template>
<template #footer>
<div class="flex flex-row gap-2">
<Button
type="button"
:disabled="processing"
variant="ghost"
@click="
() => {
close();
resetForm();
}
"
>
Prekliči
</Button>
<Button type="submit" form="segment-change-form" :disabled="processing">
Potrdi
</Button>
</div>
</template>
</DialogModal>
</template>
<style></style>

View File

@ -30,14 +30,15 @@ import AppPopover from "@/Components/app/ui/AppPopover.vue";
import InputLabel from "@/Components/InputLabel.vue"; import InputLabel from "@/Components/InputLabel.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue"; import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue"; import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { toNumber } from "lodash";
const props = defineProps({ const props = defineProps({
setting: Object, setting: Object,
unassignedContracts: Object, unassignedContracts: Object,
assignedContracts: Object, assignedContracts: Object,
users: Array, users: Array,
unassignedClients: Array, unassignedClients: [Array, Object],
assignedClients: Array, assignedClients: [Array, Object],
filters: Object, filters: Object,
}); });
@ -54,6 +55,8 @@ const filterAssignedSelectedClient = ref(
: [] : []
); );
const unassignedContractTable = ref(null);
const form = useForm({ const form = useForm({
contract_uuid: null, contract_uuid: null,
assigned_user_id: null, assigned_user_id: null,
@ -107,6 +110,14 @@ function toggleContractSelection(uuid, checked) {
console.log(selectedContractUuids.value); console.log(selectedContractUuids.value);
} }
function handleContractSelection(selected) {
selectedContractUuids.value = selected.map((val, i) => {
const num = toNumber(val);
return props.unassignedContracts.data[num].uuid;
});
}
// Format helpers (Slovenian formatting) // Format helpers (Slovenian formatting)
// Initialize search and filter from URL params // Initialize search and filter from URL params
@ -296,6 +307,7 @@ function assignSelected() {
bulkForm.contract_uuids = selectedContractUuids.value; bulkForm.contract_uuids = selectedContractUuids.value;
bulkForm.post(route("fieldjobs.assign-bulk"), { bulkForm.post(route("fieldjobs.assign-bulk"), {
onSuccess: () => { onSuccess: () => {
unassignedContractTable.value.clearSelection();
selectedContractUuids.value = []; selectedContractUuids.value = [];
bulkForm.contract_uuids = []; bulkForm.contract_uuids = [];
}, },
@ -304,7 +316,11 @@ function assignSelected() {
function cancelAssignment(contract) { function cancelAssignment(contract) {
const payload = { contract_uuid: contract.uuid }; const payload = { contract_uuid: contract.uuid };
form.transform(() => payload).post(route("fieldjobs.cancel")); form
.transform(() => payload)
.post(route("fieldjobs.cancel"), {
preserveScroll: true,
});
} }
// Column definitions for DataTableNew2 // Column definitions for DataTableNew2
@ -437,6 +453,7 @@ const assignedRows = computed(() =>
</div> </div>
</div> </div>
<DataTable <DataTable
ref="unassignedContractTable"
:columns="unassignedColumns" :columns="unassignedColumns"
:data="unassignedRows" :data="unassignedRows"
:meta="{ :meta="{
@ -449,6 +466,8 @@ const assignedRows = computed(() =>
links: unassignedContracts.links, links: unassignedContracts.links,
}" }"
row-key="uuid" row-key="uuid"
:enable-row-selection="true"
@selection:change="handleContractSelection"
:page-size="props.unassignedContracts?.per_page || 10" :page-size="props.unassignedContracts?.per_page || 10"
:page-size-options="[10, 15, 25, 50, 100]" :page-size-options="[10, 15, 25, 50, 100]"
:show-toolbar="true" :show-toolbar="true"
@ -482,7 +501,10 @@ const assignedRows = computed(() =>
<AppMultiSelect <AppMultiSelect
v-model="filterUnassignedSelectedClient" v-model="filterUnassignedSelectedClient"
:items=" :items="
(props.unassignedClients || []).map((client) => ({ (Array.isArray(props.unassignedClients)
? props.unassignedClients
: props.unassignedClients?.data || []
).map((client) => ({
value: client.uuid, value: client.uuid,
label: client.person.full_name, label: client.person.full_name,
})) }))
@ -497,14 +519,6 @@ const assignedRows = computed(() =>
</AppPopover> </AppPopover>
</div> </div>
</template> </template>
<template #cell-_select="{ row }">
<Checkbox
@update:model-value="
(checked) => toggleContractSelection(row.uuid, checked)
"
/>
</template>
<template #cell-case_person="{ row }"> <template #cell-case_person="{ row }">
<Link <Link
v-if="row.client_case?.uuid" v-if="row.client_case?.uuid"
@ -605,7 +619,10 @@ const assignedRows = computed(() =>
<AppMultiSelect <AppMultiSelect
v-model="filterAssignedSelectedClient" v-model="filterAssignedSelectedClient"
:items=" :items="
(props.assignedClients || []).map((client) => ({ (Array.isArray(props.assignedClients)
? props.assignedClients
: props.assignedClients?.data || []
).map((client) => ({
value: client.uuid, value: client.uuid,
label: client.person.full_name, label: client.person.full_name,
})) }))

View File

@ -75,13 +75,13 @@ const closeModal = () => {
</p> </p>
<!-- Other Browser Sessions --> <!-- Other Browser Sessions -->
<div v-if="sessions.length > 0" class="space-y-4"> <div v-if="sessions && sessions.length > 0" class="space-y-4">
<div <div
v-for="(session, i) in sessions" v-for="(session, i) in sessions"
:key="i" :key="i"
class="flex items-center gap-3 rounded-lg border p-3" class="flex items-center gap-3 rounded-lg border p-3"
> >
<div class="flex-shrink-0"> <div class="shrink-0">
<Monitor <Monitor
v-if="session.agent.is_desktop" v-if="session.agent.is_desktop"
class="h-8 w-8 text-muted-foreground" class="h-8 w-8 text-muted-foreground"
@ -108,6 +108,14 @@ const closeModal = () => {
</div> </div>
</div> </div>
<!-- Empty State -->
<div v-else class="rounded-lg border border-dashed p-8 text-center">
<Monitor class="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p class="text-sm text-muted-foreground">
No active sessions found. This feature requires session data to be configured in your Laravel application.
</p>
</div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Button @click="confirmLogout"> <Button @click="confirmLogout">
<LogOut class="h-4 w-4 mr-2" /> <LogOut class="h-4 w-4 mr-2" />

View File

@ -1,10 +1,11 @@
<script setup> <script setup>
import AppLayout from "@/Layouts/AppLayout.vue"; import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3"; import { Link, router, useForm, usePage } from "@inertiajs/vue3";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import axios from "axios"; import axios from "axios";
import DataTable from "@/Components/DataTable/DataTableNew2.vue"; import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import DialogModal from "@/Components/DialogModal.vue"; import DialogModal from "@/Components/DialogModal.vue";
import ConfirmDialog from "@/Components/ConfirmDialog.vue";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label"; import { Label } from "@/Components/ui/label";
@ -30,6 +31,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import AppCard from "@/Components/app/ui/card/AppCard.vue"; import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { CardTitle } from "@/Components/ui/card"; import { CardTitle } from "@/Components/ui/card";
import { toNumber } from "lodash";
const props = defineProps({ const props = defineProps({
segment: Object, segment: Object,
@ -63,6 +65,14 @@ const exportColumns = ref(columns.map((col) => col.key));
const exportError = ref(""); const exportError = ref("");
const isExporting = ref(false); const isExporting = ref(false);
const contractTable = ref(null);
const selectedRows = ref([]);
const showConfirmDialog = ref(false);
const archiveForm = useForm({
contracts: [],
reactivate: false,
});
const hasActiveFilters = computed(() => { const hasActiveFilters = computed(() => {
return Boolean(search.value?.trim()) || Boolean(selectedClient.value); return Boolean(search.value?.trim()) || Boolean(selectedClient.value);
}); });
@ -78,6 +88,13 @@ const appliedFilterCount = computed(() => {
return count; return count;
}); });
function handleSelectionChange(selectedKeys) {
selectedRows.value = selectedKeys.map((val, i) => {
const nu = toNumber(val);
return props.contracts.data[nu].uuid;
});
}
const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1); const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1);
const contractsPerPage = computed(() => props.contracts?.per_page ?? 15); const contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
const totalContracts = computed( const totalContracts = computed(
@ -317,43 +334,9 @@ function extractFilenameFromHeaders(headers) {
return asciiMatch?.[1] || null; return asciiMatch?.[1] || null;
} }
function toggleSelectAll() {
if (selectedRows.value.length === props.contracts.data.length) {
selectedRows.value = [];
} else {
selectedRows.value = props.contracts.data.map((row) => row.uuid);
}
}
function toggleRowSelection(uuid) {
const index = selectedRows.value.indexOf(uuid);
if (index > -1) {
selectedRows.value.splice(index, 1);
} else {
selectedRows.value.push(uuid);
}
}
function isRowSelected(uuid) {
return selectedRows.value.includes(uuid);
}
function isAllSelected() {
return (
props.contracts.data.length > 0 &&
selectedRows.value.length === props.contracts.data.length
);
}
function isIndeterminate() {
return (
selectedRows.value.length > 0 &&
selectedRows.value.length < props.contracts.data.length
);
}
function openArchiveModal() { function openArchiveModal() {
if (!selectedRows.value.length) return; console.log(selectedRows.value);
if (!selectedRows.value?.length) return;
showConfirmDialog.value = true; showConfirmDialog.value = true;
} }
@ -362,7 +345,7 @@ function closeConfirmDialog() {
} }
function submitArchive() { function submitArchive() {
if (!selectedRows.value.length) return; if (!selectedRows.value?.length) return;
showConfirmDialog.value = false; showConfirmDialog.value = false;
@ -373,6 +356,9 @@ function submitArchive() {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
selectedRows.value = []; selectedRows.value = [];
if (contractTable.value) {
contractTable.value.clearSelection();
}
router.reload({ only: ["contracts"] }); router.reload({ only: ["contracts"] });
}, },
}); });
@ -430,10 +416,13 @@ function submitArchive() {
</div> </div>
</template> </template>
<DataTable <DataTable
ref="contractTable"
:columns="columns" :columns="columns"
:data="contracts?.data || []" :data="contracts?.data || []"
:meta="contracts || {}" :meta="contracts || {}"
route-name="segments.show" route-name="segments.show"
:enable-row-selection="canManageSettings"
@selection:change="handleSelectionChange"
:route-params="{ segment: segment?.id ?? segment }" :route-params="{ segment: segment?.id ?? segment }"
:only-props="['contracts']" :only-props="['contracts']"
:page-size="contracts?.per_page ?? 15" :page-size="contracts?.per_page ?? 15"
@ -566,6 +555,17 @@ function submitArchive() {
</Button> </Button>
</div> </div>
</template> </template>
<template #toolbar-actions="{ table }">
<Button
v-if="canManageSettings && table?.getSelectedRowModel()?.rows?.length > 0"
variant="destructive"
size="sm"
class="gap-2"
@click="openArchiveModal"
>
Arhiviraj ({{ table.getSelectedRowModel().rows.length }})
</Button>
</template>
<template #cell-client_case="{ row }"> <template #cell-client_case="{ row }">
<Link <Link
@ -610,8 +610,10 @@ function submitArchive() {
<ConfirmDialog <ConfirmDialog
:show="showConfirmDialog" :show="showConfirmDialog"
title="Arhiviraj pogodbe" title="Arhiviraj pogodbe"
:message="`Ali ste prepričani, da želite arhivirati ${selectedRows.length} pogodb${ :message="`Ali ste prepričani, da želite arhivirati ${
selectedRows.length === 1 ? 'o' : '' selectedRows?.length || 0
} pogodb${
selectedRows?.length === 1 ? 'o' : ''
}? Arhivirane pogodbe bodo odstranjene iz aktivnih segmentov.`" }? Arhivirane pogodbe bodo odstranjene iz aktivnih segmentov.`"
confirm-text="Arhiviraj" confirm-text="Arhiviraj"
cancel-text="Prekliči" cancel-text="Prekliči"

View File

@ -203,7 +203,14 @@
->leftJoin('person_addresses', 'person.id', '=', 'person_addresses.person_id') ->leftJoin('person_addresses', 'person.id', '=', 'person_addresses.person_id')
->leftJoin('person_phones', 'person.id', '=', 'person_phones.person_id') ->leftJoin('person_phones', 'person.id', '=', 'person_phones.person_id')
->leftJoin('emails', 'person.id', '=', 'emails.person_id') ->leftJoin('emails', 'person.id', '=', 'emails.person_id')
->select('person.*', 'client_cases.uuid as case_uuid', 'client_cases.id as case_id') ->leftJoin('clients', 'clients.id', '=', 'client_cases.client_id')
->leftJoin('person as client_person', 'client_person.id', '=', 'clients.person_id')
->select(
'person.*',
'client_cases.uuid as case_uuid',
'client_cases.id as case_id',
'client_person.full_name as client_full_name'
)
->limit($request->input('limit')); ->limit($request->input('limit'));
}) })
->get(); ->get();
@ -215,6 +222,8 @@
$contractCases = \App\Models\Contract::query() $contractCases = \App\Models\Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->join('person', 'client_cases.person_id', '=', 'person.id') ->join('person', 'client_cases.person_id', '=', 'person.id')
->leftJoin('clients', 'clients.id', '=', 'client_cases.client_id')
->leftJoin('person as client_person', 'client_person.id', '=', 'clients.person_id')
->leftJoin('contract_segment', function ($j) { ->leftJoin('contract_segment', function ($j) {
$j->on('contract_segment.contract_id', '=', 'contracts.id') $j->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true); ->where('contract_segment.active', true);
@ -227,9 +236,10 @@
'client_cases.uuid as case_uuid', 'client_cases.uuid as case_uuid',
'client_cases.id as case_id', 'client_cases.id as case_id',
'contracts.reference as contract_reference', 'contracts.reference as contract_reference',
'client_person.full_name as client_full_name',
\DB::raw("COALESCE(json_agg(DISTINCT jsonb_build_object('id', segments.id, 'name', segments.name)) FILTER (WHERE segments.id IS NOT NULL), '[]') as contract_segments") \DB::raw("COALESCE(json_agg(DISTINCT jsonb_build_object('id', segments.id, 'name', segments.name)) FILTER (WHERE segments.id IS NOT NULL), '[]') as contract_segments")
) )
->groupBy('person.id', 'client_cases.uuid', 'client_cases.id', 'contracts.reference') ->groupBy('person.id', 'client_cases.uuid', 'client_cases.id', 'contracts.reference', 'client_person.full_name')
->limit($limit) ->limit($limit)
->get(); ->get();