Package and individual mail sender, new report, and other changes

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Simon Pocrnjič
2026-05-11 21:32:30 +02:00
parent b6bfa17980
commit e3bc5da7e3
49 changed files with 4754 additions and 249 deletions
@@ -43,6 +43,8 @@ const formSchema = toTypedSchema(
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
label: z.string().optional(),
receive_auto_mails: z.boolean().optional(),
valid: z.boolean().default(true),
failed: z.boolean().default(false),
decision_ids: z.array(z.string()).optional().default([]),
})
);
@@ -54,6 +56,8 @@ const form = useForm({
value: "",
label: "",
receive_auto_mails: false,
valid: true,
failed: false,
decision_ids: [],
},
});
@@ -78,6 +82,8 @@ const resetForm = () => {
value: "",
label: "",
receive_auto_mails: false,
valid: true,
failed: false,
decision_ids: [],
},
});
@@ -182,6 +188,8 @@ watch(
value: email.value ?? email.email ?? email.address ?? "",
label: email.label ?? "",
receive_auto_mails: !!email.receive_auto_mails,
valid: email.valid !== undefined ? !!email.valid : true,
failed: !!email.failed,
decision_ids: existingDecisionIds,
});
} else {
@@ -272,6 +280,28 @@ const onConfirm = () => {
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="valid">
<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 class="cursor-pointer">Veljavna</FormLabel>
</div>
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="failed">
<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 class="cursor-pointer">Neuspešna dostava</FormLabel>
</div>
</FormItem>
</FormField>
<!-- Limit to specific decisions only shown when receive_auto_mails is on and decisions exist -->
<template v-if="(props.person?.client || isClientContext) && form.values.receive_auto_mails && decisionOptions.length > 0">
<div class="flex flex-row items-start space-x-3 space-y-0">
@@ -0,0 +1,483 @@
<script setup>
import { ref, watch, computed, nextTick } from "vue";
import axios from "axios";
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 { Button } from "@/Components/ui/button";
import { ScrollArea } from "@/Components/ui/scroll-area";
const props = defineProps({
show: { type: Boolean, default: false },
email: { type: Object, default: null },
clientCaseUuid: { type: String, default: null },
emailTemplates: { type: Array, default: () => [] },
mailProfiles: { type: Array, default: () => [] },
});
const emit = defineEmits(["close"]);
const page = usePage();
const pageProps = computed(() => page?.props ?? {});
const pageEmailTemplates = computed(() => {
const fromProps =
Array.isArray(props.emailTemplates) && props.emailTemplates.length
? props.emailTemplates
: null;
return fromProps ?? pageProps.value?.email_templates ?? [];
});
const pageMailProfiles = computed(() => {
const fromProps =
Array.isArray(props.mailProfiles) && props.mailProfiles.length
? props.mailProfiles
: null;
return fromProps ?? pageProps.value?.mail_profiles ?? [];
});
// Form schema
const formSchema = toTypedSchema(
z.object({
subject: z.string().min(1, "Zadeva je obvezna.").max(255),
html_body: z.string().nullable().optional(),
body_text: z.string().max(10000).nullable().optional(),
template_id: z.number().nullable().optional(),
mail_profile_id: z.number().nullable().optional(),
contract_uuid: z.string().nullable().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
subject: "",
html_body: "",
body_text: "",
template_id: null,
mail_profile_id: null,
contract_uuid: null,
},
});
const processing = ref(false);
const contractsForCase = ref([]);
const hasBodyText = ref(false); // whether selected template uses {{body_text}}
// WYSIWYG iframe
const iframeRef = ref(null);
let iframeSyncing = false;
function ensureFullDoc(html) {
if (!html) {
return '<!doctype html><html><head><meta charset="utf-8" /></head><body></body></html>';
}
if (/<html[\s\S]*<\/html>/i.test(html)) return html;
return `<!doctype html><html><head><meta charset="utf-8" /></head><body>${html}</body></html>`;
}
function writeIframeDocument(html) {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
const full = ensureFullDoc(html ?? form.values.html_body ?? "");
doc.open();
doc.write(full);
doc.close();
try {
doc.body.setAttribute("spellcheck", "false");
} catch {}
}
function initIframeEditor(html) {
writeIframeDocument(html);
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.designMode = "on";
} catch {}
const syncHandler = () => {
if (iframeSyncing) return;
try {
iframeSyncing = true;
const full = doc.documentElement.outerHTML;
form.setFieldValue("html_body", full);
} finally {
iframeSyncing = false;
}
};
doc.removeEventListener("input", syncHandler);
doc.removeEventListener("keyup", syncHandler);
doc.addEventListener("input", syncHandler);
doc.addEventListener("keyup", syncHandler);
}
function iframeExec(command) {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.body.focus();
} catch {}
try {
doc.execCommand(command, false, null);
} catch (e) {
console.warn("execCommand failed", command, e);
}
}
// Load template preview from server
const loadingPreview = ref(false);
const updateFromTemplate = async () => {
if (!form.values.template_id || !props.clientCaseUuid) return;
loadingPreview.value = true;
try {
const url = route("clientCase.email.preview", {
client_case: props.clientCaseUuid,
email_id: props.email?.id,
});
const { data } = await axios.post(url, {
template_id: form.values.template_id,
contract_uuid: form.values.contract_uuid || null,
body_text: form.values.body_text || "",
});
const hadBodyText = hasBodyText.value;
hasBodyText.value = !!data?.has_body_text;
// Pre-fill body_text from text_template when the placeholder is present and field is empty
if (data?.has_body_text && !hadBodyText) {
const tpl = pageEmailTemplates.value.find((t) => t.id === form.values.template_id);
if (tpl?.text_template && !form.values.body_text) {
form.setFieldValue("body_text", tpl.text_template);
}
}
if (data?.subject) {
form.setFieldValue("subject", data.subject);
}
const html = data?.html ?? "";
form.setFieldValue("html_body", html);
await nextTick();
initIframeEditor(html);
} catch (e) {
// ignore
} finally {
loadingPreview.value = false;
}
};
watch(
() => form.values.template_id,
() => {
updateFromTemplate();
}
);
watch(
() => form.values.contract_uuid,
() => {
if (form.values.template_id) {
updateFromTemplate();
}
}
);
// Re-preview when body_text changes (debounce-like: only when a template is active)
watch(
() => form.values.body_text,
() => {
if (form.values.template_id && hasBodyText.value) {
updateFromTemplate();
}
}
);
const loadContractsForCase = async () => {
try {
const url = route("clientCase.contracts.list", {
client_case: props.clientCaseUuid,
});
const res = await fetch(url, {
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin",
});
const json = await res.json();
contractsForCase.value = Array.isArray(json?.data) ? json.data : [];
} catch (e) {
contractsForCase.value = [];
}
};
watch(
() => props.show,
async (newVal) => {
if (newVal) {
form.resetForm({
values: {
subject: "",
html_body: "",
body_text: "",
template_id: null,
mail_profile_id: pageMailProfiles.value?.[0]?.id ?? null,
contract_uuid: null,
},
});
hasBodyText.value = false;
contractsForCase.value = [];
await loadContractsForCase();
// Init empty iframe
await nextTick();
initIframeEditor("");
}
}
);
const closeDialog = () => {
emit("close");
};
const onSubmit = form.handleSubmit((values) => {
if (!props.email || !props.clientCaseUuid) return;
processing.value = true;
router.post(
route("clientCase.email.send", {
client_case: props.clientCaseUuid,
email_id: props.email.id,
}),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
closeDialog();
},
onError: () => {
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
});
const open = computed({
get: () => props.show,
set: (value) => {
if (!value) closeDialog();
},
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Pošlji e-pošto</DialogTitle>
<DialogDescription>
<p class="text-sm text-gray-600">
Prejemnik:
<span class="font-mono">{{ email?.value || email?.email || email?.address }}</span>
</p>
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[70vh] pr-1">
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
<!-- Mail profile -->
<FormField v-slot="{ value, handleChange }" name="mail_profile_id">
<FormItem>
<FormLabel>E-poštni 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 pageMailProfiles"
:key="p.id"
:value="p.id"
>
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<!-- Contract -->
<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">
Izberite pogodbo za zapolnitev spremenljivk v predlogi.
</p>
<FormMessage />
</FormItem>
</FormField>
<!-- Template -->
<FormField v-slot="{ value, handleChange }" name="template_id">
<FormItem>
<FormLabel>Predloga</FormLabel>
<Select
:model-value="value"
@update:model-value="handleChange"
:disabled="loadingPreview"
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="t in pageEmailTemplates"
:key="t.id"
:value="t.id"
>
{{ t.name || "Predloga #" + t.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<!-- Subject -->
<FormField v-slot="{ componentField }" name="subject">
<FormItem>
<FormLabel>Zadeva</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Zadeva e-poštnega sporočila..."
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- body_text textarea shown only when the template uses {{body_text}} -->
<FormField v-if="hasBodyText" v-slot="{ componentField }" name="body_text">
<FormItem>
<FormLabel>Besedilo sporočila</FormLabel>
<FormControl>
<Textarea
placeholder="Vnesite besedilo, ki se vstavi na mesto {{body_text}} v predlogi..."
class="min-h-[120px] resize-y"
v-bind="componentField"
/>
</FormControl>
<p class="mt-1 text-xs text-gray-500">
Besedilo se vstavi na oznako <code>&#123;&#123;body_text&#125;&#125;</code> v predlogi. Besedilo ne podpira spremenljivk.
</p>
<FormMessage />
</FormItem>
</FormField>
<!-- WYSIWYG body editor -->
<div>
<label class="text-sm font-medium leading-none">Vsebina</label>
<!-- Toolbar -->
<div class="flex gap-1 mt-2 mb-1 border rounded-t-md bg-gray-50 p-1">
<Button
type="button"
size="sm"
variant="ghost"
class="font-bold px-2 py-1 h-7"
title="Krepko (Ctrl+B)"
@click="iframeExec('bold')"
>B</Button>
<Button
type="button"
size="sm"
variant="ghost"
class="italic px-2 py-1 h-7"
title="Poševno (Ctrl+I)"
@click="iframeExec('italic')"
>I</Button>
<Button
type="button"
size="sm"
variant="ghost"
class="underline px-2 py-1 h-7"
title="Podčrtano (Ctrl+U)"
@click="iframeExec('underline')"
>U</Button>
</div>
<iframe
ref="iframeRef"
class="w-full border rounded-b-md bg-white"
style="min-height: 240px; max-height: 360px"
frameborder="0"
sandbox="allow-same-origin allow-scripts"
/>
<p class="mt-1 text-xs text-gray-500">
Kliknite v vsebino in začnite pisati. Izberite predlogo za samodejno zapolnitev.
</p>
</div>
</form>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="closeDialog" :disabled="processing">
Prekliči
</Button>
<Button
@click="onSubmit"
:disabled="processing || !form.values.subject"
>
{{ processing ? "Pošiljanje..." : "Pošlji" }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -8,14 +8,16 @@ import {
} from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card";
import { Button } from "../ui/button";
import { EllipsisVertical } from "lucide-vue-next";
import { CircleCheckBigIcon, CircleXIcon, EllipsisVertical, MailIcon } from "lucide-vue-next";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
enableEmail: { type: Boolean, default: false },
});
const emit = defineEmits(["add", "edit", "delete"]);
const emit = defineEmits(["add", "edit", "delete", "email"]);
const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []);
@@ -44,7 +46,17 @@ const handleDelete = (id, label) => emit("delete", id, label);
</span>
</div>
<div v-if="edit">
<DropdownMenu>
<div class="flex items-center gap-1">
<Button
v-if="enableEmail"
@click="$emit('email', email)"
title="Pošlji e-pošto"
size="icon"
variant="ghost"
>
<MailIcon :size="18" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" title="Možnosti">
<EllipsisVertical />
@@ -66,11 +78,28 @@ const handleDelete = (id, label) => emit("delete", id, label);
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div class="p-1">
<p class="font-medium text-gray-900 leading-relaxed">
<p class="font-medium text-gray-900 leading-relaxed flex gap-1 items-center">
{{ email?.value || email?.email || email?.address || "-" }}
<TooltipProvider v-if="email?.valid">
<Tooltip>
<TooltipTrigger as-child>
<CircleCheckBigIcon color="#3e9392" :size="18" />
</TooltipTrigger>
<TooltipContent>Veljavna</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider v-if="email?.failed">
<Tooltip>
<TooltipTrigger as-child>
<CircleXIcon color="#dc2626" :size="18" />
</TooltipTrigger>
<TooltipContent>Neuspešna dostava</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
<p
v-if="email?.note"
@@ -30,6 +30,7 @@ import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
import PersonInfoEmailDialog from "./PersonInfoEmailDialog.vue";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
@@ -58,6 +59,9 @@ const props = defineProps({
smsProfiles: { type: Array, default: () => [] },
smsSenders: { type: Array, default: () => [] },
smsTemplates: { type: Array, default: () => [] },
enableEmail: { type: Boolean, default: false },
emailTemplates: { type: Array, default: () => [] },
mailProfiles: { type: Array, default: () => [] },
});
// Dialog states
@@ -91,6 +95,10 @@ const confirm = ref({
const showSmsDialog = ref(false);
const smsTargetPhone = ref(null);
// Email dialog state
const showEmailDialog = ref(false);
const emailTarget = ref(null);
// Person handlers
const openDrawerUpdateClient = () => {
drawerUpdatePerson.value = true;
@@ -251,6 +259,18 @@ const closeSmsDialog = () => {
smsTargetPhone.value = null;
};
// Email dialog handlers
const openEmailDialog = (email) => {
if (!props.enableEmail || !props.clientCaseUuid) return;
emailTarget.value = email;
showEmailDialog.value = true;
};
const closeEmailDialog = () => {
showEmailDialog.value = false;
emailTarget.value = null;
};
// Tab event handlers
const handlePersonEdit = () => openDrawerUpdateClient();
@@ -266,6 +286,7 @@ const handlePhoneSms = (phone) => openSmsDialog(phone);
const handleEmailAdd = () => openDrawerAddEmail(false, 0);
const handleEmailEdit = (id) => openDrawerAddEmail(true, id);
const handleEmailDelete = (id, label) => openConfirm("email", id, label);
const handleEmailSend = (email) => openEmailDialog(email);
const handleTrrAdd = () => openDrawerAddTrr(false, 0);
const handleTrrEdit = (id) => openDrawerAddTrr(true, id);
@@ -418,9 +439,11 @@ const switchToTab = (tab) => {
<PersonInfoEmailsTab
:person="person"
:edit="edit"
:enable-email="enableEmail && !!clientCaseUuid"
@add="handleEmailAdd"
@edit="handleEmailEdit"
@delete="handleEmailDelete"
@email="handleEmailSend"
/>
</TabsContent>
@@ -534,4 +557,15 @@ const switchToTab = (tab) => {
:sms-templates="smsTemplates"
@close="closeSmsDialog"
/>
<!-- Email Dialog -->
<PersonInfoEmailDialog
v-if="clientCaseUuid"
:show="showEmailDialog"
:email="emailTarget"
:client-case-uuid="clientCaseUuid"
:email-templates="emailTemplates"
:mail-profiles="mailProfiles"
@close="closeEmailDialog"
/>
</template>