changes UI

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons";
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
const props = defineProps({
@ -15,17 +15,7 @@ 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="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-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"
@ -80,6 +70,14 @@ const handleDelete = (id, label) => emit('delete', id, label);
}}
</p>
</div>
<button
v-if="edit"
@click="handleAdd"
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
title="Dodaj naslov"
>
<PlusIcon class="h-8 w-8 text-gray-400" />
</button>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import { Switch } from "@/Components/ui/switch";
const props = defineProps({
show: {
@ -216,7 +216,7 @@ const onSubmit = form.handleSubmit(() => {
<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" />
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Potrjeno</FormLabel>

View File

@ -20,7 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import { Switch } from "@/Components/ui/switch";
const props = defineProps({
show: {
@ -241,7 +241,7 @@ const onSubmit = form.handleSubmit(() => {
<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" />
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Potrjeno</FormLabel>

View File

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

View File

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

View File

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