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
@@ -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">
<p class="text-sm text-gray-600">
Prejemnik: <span class="font-mono">{{ phone?.nu }}</span>
<span v-if="phone?.country_code" class="ml-2 text-xs text-gray-500"
>CC +{{ phone.country_code }}</span
>
</p>
<!-- Profile & Sender selectors -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<label class="block text-sm font-medium text-gray-700">Profil</label>
<select
v-model="selectedProfileId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="p in pageSmsProfiles" :key="p.id" :value="p.id">
{{ p.name || "Profil #" + p.id }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Pošiljatelj</label>
<select
v-model="selectedSenderId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="s in sendersForSelectedProfile" :key="s.id" :value="s.id">
{{ s.name || s.phone || "Sender #" + s.id }}
</option>
</select>
</div>
</div>
<!-- Contract selector -->
<div>
<label class="block text-sm font-medium text-gray-700">Pogodba</label>
<select
v-model="selectedContractUuid"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="c in contractsForCase" :key="c.uuid" :value="c.uuid">
{{ c.reference || c.uuid }}
</option>
</select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in {account.*}
mest.
<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>
</p>
</DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField v-slot="{ value, handleChange }" name="profile_id">
<FormItem>
<FormLabel>Profil</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="p in pageSmsProfiles"
:key="p.id"
:value="p.id"
>
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="sender_id">
<FormItem>
<FormLabel>Pošiljatelj</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="s in sendersForSelectedProfile"
:key="s.id"
:value="s.id"
>
{{ s.name || s.phone || "Sender #" + s.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Template selector -->
<div>
<label class="block text-sm font-medium text-gray-700">Predloga</label>
<select
v-model="selectedTemplateId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
{{ t.name || "Predloga #" + t.id }}
</option>
</select>
</div>
<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"
>
{{ c.reference || c.uuid }}
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in
{account.*} mest.
</p>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="template_id">
<FormItem>
<FormLabel>Predloga</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="t in pageSmsTemplates"
:key="t.id"
:value="t.id"
>
{{ t.name || "Predloga #" + t.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>Vsebina sporočila</FormLabel>
<FormControl>
<Textarea
rows="4"
placeholder="Vpišite SMS vsebino..."
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<label class="block text-sm font-medium text-gray-700">Vsebina sporočila</label>
<textarea
v-model="smsMessage"
rows="4"
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Vpišite SMS vsebino..."
></textarea>
<!-- Live counters -->
<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"
/>
Zahtevaj poročilo o dostavi
</label>
</div>
</template>
<template #footer>
<button class="px-3 py-1 rounded border mr-2" @click="closeSmsDialog">
Prekliči
</button>
<button
class="px-3 py-1 rounded bg-indigo-600 text-white disabled:opacity-50"
:disabled="smsSending || !smsMessage"
@click="submitSms"
>
Pošlji
</button>
</template>
</DialogModal>
</template>
<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"
/>
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>Zahtevaj poročilo o dostavi</FormLabel>
</div>
</FormItem>
</FormField>
</form>
<DialogFooter>
<Button variant="outline" @click="closeSmsDialog" :disabled="processing">
Prekliči
</Button>
<Button
@click="onSubmit"
:disabled="processing || !form.values.message"
>
Pošlji
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>