Files
2026-05-11 21:32:30 +02:00

484 lines
14 KiB
Vue

<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>