Teren-app/resources/js/Pages/Admin/EmailTemplates/Edit.vue
2026-01-05 18:27:35 +01:00

1518 lines
46 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Head, Link, useForm, router, usePage } from "@inertiajs/vue3";
import { ref, watch, computed, onMounted, nextTick } from "vue";
import {
ArrowLeftIcon,
EyeIcon,
SaveIcon,
BoldIcon,
ItalicIcon,
UnderlineIcon,
AlignLeftIcon,
AlignCenterIcon,
AlignRightIcon,
ListIcon,
ListOrderedIcon,
ImageIcon,
Maximize2Icon,
CodeIcon,
SendIcon,
CopyIcon,
DownloadIcon,
Trash2Icon,
FileTextIcon,
SettingsIcon,
UserIcon,
BriefcaseIcon,
FolderIcon,
ActivityIcon,
PlusCircleIcon,
} from "lucide-vue-next";
import { VueMonacoEditor } from "@guolao/vue-monaco-editor";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
import { Checkbox } from "@/Components/ui/checkbox";
import { Switch } from "@/Components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Separator } from "@/Components/ui/separator";
import { Badge } from "@/Components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
// Keep Quill CSS for nicer preview styling, but remove the Quill editor itself
import "quill/dist/quill.snow.css";
const props = defineProps({
template: { type: Object, default: null },
});
const form = useForm({
name: props.template?.name ?? "",
key: props.template?.key ?? "",
subject_template: props.template?.subject_template ?? "",
html_template: props.template?.html_template ?? "",
text_template: props.template?.text_template ?? "",
entity_types: props.template?.entity_types ?? ["client", "contract"],
allow_attachments: props.template?.allow_attachments ?? false,
active: props.template?.active ?? true,
});
const preview = ref({ subject: "", html: "", text: "" });
const finalHtml = ref("");
const finalAttachments = ref([]);
const showFinal = ref(false);
const finalEmbedMode = ref("base64"); // 'base64' (default) or 'hosted' for diagnostics
const sendEmbedMode = ref("base64"); // embed mode for Send Test action
const isLocalHost = computed(() => {
if (typeof window === "undefined") return false;
const h = window.location.hostname || "";
return /^(localhost|127\.0\.0\.1)$/i.test(h);
});
// If preview.html is empty (e.g., because only head/styles were changed), show the body-safe content from the current source as a fallback.
const previewHtml = computed(() => {
const html = preview.value?.html || "";
if (html.trim() !== "") {
return containsDocScaffold(html) ? extractBody(html) : html;
}
// Fallback to local content if server preview didn't provide HTML
// We only use the advanced editor now, so just show the <body> content when available
return extractBody(form.html_template || "");
});
const docsRaw = ref(props.template?.documents ? [...props.template.documents] : []);
const docs = computed(() =>
(docsRaw.value ?? []).map((d) => ({
...d,
url: d?.path ? `/storage/${d.path}` : null,
}))
);
function updateLocalDoc(documentId, path, name = null, size = null) {
if (!documentId) return;
const idx = docsRaw.value.findIndex((d) => d.id === documentId);
if (idx !== -1) {
const next = { ...docsRaw.value[idx] };
next.path = path ?? next.path;
if (name) next.name = name;
if (size != null) next.size = size;
docsRaw.value.splice(idx, 1, next);
}
}
async function removeLocalDoc(documentId) {
const idx = docsRaw.value.findIndex((d) => d.id === documentId);
if (idx !== -1) {
docsRaw.value.splice(idx, 1);
}
}
function formatSize(bytes) {
if (!bytes && bytes !== 0) return "";
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(2)} MB`;
}
const debounce = (fn, ms = 500) => {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
};
// Core preview fetcher; mirrors values when creating (no ID), calls backend when editing
const fetchPreview = () => {
if (!props.template?.id) {
preview.value = {
subject: form.subject_template,
html: form.html_template,
text: form.text_template,
};
return;
}
window.axios
.post(route("admin.email-templates.preview", props.template.id), {
subject: form.subject_template,
// Always send full HTML so head/styles can be respected and inlined server-side
html: form.html_template,
text: form.text_template,
activity_id: sample.value.activity_id || undefined,
client_id: sample.value.client_id || undefined,
case_id: sample.value.case_id || undefined,
contract_id: sample.value.contract_id || undefined,
extra: safeParseExtra(sample.value.extra),
})
.then((r) => {
preview.value = r.data;
});
};
const doPreview = debounce(fetchPreview, 400);
watch(
() => [form.subject_template, form.html_template, form.text_template],
() => doPreview(),
{ deep: true, immediate: true }
);
function submit() {
if (props.template?.id) {
form.put(route("admin.email-templates.update", props.template.id));
} else {
form.post(route("admin.email-templates.store"));
}
}
async function fetchFinalHtml() {
const { data } = await window.axios.post(
route("admin.email-templates.render-final", props.template.id),
{
subject: form.subject_template,
html: form.html_template,
text: form.text_template,
activity_id: sample.value.activity_id || undefined,
client_id: sample.value.client_id || undefined,
case_id: sample.value.case_id || undefined,
contract_id: sample.value.contract_id || undefined,
extra: safeParseExtra(sample.value.extra),
embed: finalEmbedMode.value,
}
);
finalHtml.value = data?.html || "";
finalAttachments.value = Array.isArray(data?.attachments) ? data.attachments : [];
}
async function openFinalHtmlDialog() {
if (!props.template?.id) {
alert("Najprej shranite predlogo.");
return;
}
try {
await fetchFinalHtml();
showFinal.value = true;
} catch (e) {
console.error("Failed to render final HTML", e);
alert("Neuspešen prikaz končne HTML vsebine.");
}
}
function copyFinalHtml() {
try {
navigator.clipboard.writeText(finalHtml.value || "");
alert("Končni HTML je kopiran v odložišče.");
} catch (e) {
console.warn("Clipboard write failed", e);
}
}
function downloadFinalHtml() {
const blob = new Blob([finalHtml.value || ""], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${(form.name || "email-template").replace(/[^a-z0-9-_]+/gi, "-")}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
onMounted(() => {
// Show something instantly and request preview immediately
preview.value = {
subject: form.subject_template,
html: form.html_template,
text: form.text_template,
};
// Populate cascading selects immediately so the Client dropdown isn't empty
loadClients();
fetchPreview();
// Advanced editor is the only mode
activeField.value = "html";
initIframeEditor();
});
// --- Variable insertion and sample entity selection ---
const subjectRef = ref(null);
const htmlSourceRef = ref(null);
const monacoEditor = ref(null);
const textRef = ref(null);
const activeField = ref("html"); // default to HTML for variable inserts
// Raw HTML editor toggle (full-document source)
const rawMode = ref(false);
// Advanced full-document editor that renders styles from <head>
const advancedMode = ref(true);
const iframeRef = ref(null);
let iframeSyncing = false;
const selectedImageSrc = ref("");
// Preview iframe ref (to render preview HTML with <head> styles applied)
const previewIframeRef = ref(null);
// Detect and handle full-document HTML so Quill doesn't wipe content
function containsDocScaffold(html) {
if (!html || typeof html !== "string") return false;
const h = html.toLowerCase();
return (
h.includes("<!doctype") ||
h.includes("<html") ||
h.includes("<head") ||
h.includes("<body") ||
h.includes("<style")
);
}
function extractBody(html) {
const m = /<body[^>]*>([\s\S]*?)<\/body>/i.exec(html);
return m ? m[1] : html;
}
// Retained for compatibility, but no longer used actively
function stripDocScaffold(html) {
return extractBody(html || "");
}
// Replace only the inner content of <body>...</body> in a full document
function replaceBody(htmlDoc, newBody) {
if (!htmlDoc || typeof htmlDoc !== "string") return newBody || "";
const hasBody = /<body[^>]*>[\s\S]*?<\/body>/i.test(htmlDoc);
if (!hasBody) {
return newBody || "";
}
return htmlDoc.replace(/(<body[^>]*>)[\s\S]*?(<\/body>)/i, `$1${newBody || ""}$2`);
}
// Quill/source mode removed
// Advanced mode is always on
// When HTML changes externally, reflect it into iframe (unless we're the ones syncing)
watch(
() => form.html_template,
async () => {
if (iframeSyncing) return;
await nextTick();
writeIframeDocument();
}
);
// Re-initialize iframe editor when switching back from Raw HTML
watch(
() => rawMode.value,
async (on) => {
if (!on) {
await nextTick();
initIframeEditor();
}
}
);
function ensureFullDoc(html) {
if (!html)
return '<!doctype html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /></head><body></body></html>';
if (/<html[\s\S]*<\/html>/i.test(html)) return html;
return `<!doctype html><html><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /></head><body>${html}</body></html>`;
}
function writeIframeDocument() {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
const html = ensureFullDoc(form.html_template || "");
doc.open();
doc.write(html);
doc.close();
try {
doc.body.setAttribute("spellcheck", "false");
} catch {}
}
function initIframeEditor() {
writeIframeDocument();
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.designMode = "on";
} catch {}
// track selected image src on click
doc.addEventListener("click", (e) => {
const t = e.target;
if (t && t.tagName === "IMG") {
selectedImageSrc.value = t.getAttribute("src") || "";
} else {
selectedImageSrc.value = "";
}
// Ensure variable buttons target HTML editor
activeField.value = "html";
});
const handler = debounce(() => {
if (!advancedMode.value) return;
try {
iframeSyncing = true;
const full = doc.documentElement.outerHTML;
form.html_template = full;
} finally {
iframeSyncing = false;
}
}, 250);
doc.addEventListener("input", handler);
doc.addEventListener("keyup", handler);
}
function writePreviewDocument() {
const iframe = previewIframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
const html = ensureFullDoc(preview.value?.html || form.html_template || "");
doc.open();
doc.write(html);
doc.close();
}
function iframeExec(command, value = null) {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.body.focus();
} catch {}
try {
doc.execCommand(command, false, value);
} catch (e) {
console.warn("execCommand failed", command, e);
}
}
async function iframeInsertImage() {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const data = new FormData();
data.append("file", file);
try {
const { data: res } = await window.axios.post(
route("admin.email-templates.upload-image"),
data,
{ headers: { "Content-Type": "multipart/form-data" } }
);
const url = res?.url;
const documentId = res?.document_id;
if (url) {
iframeExec("insertImage", url);
}
} catch (e) {
console.error("Image upload failed", e);
}
};
input.click();
}
async function iframeReplaceSelectedImage() {
if (!props.template?.id) {
alert("Najprej shranite predlogo, nato zamenjajte sliko.");
return;
}
if (!selectedImageSrc.value) {
alert("Najprej kliknite na sliko v dokumentu, ki jo želite zamenjati.");
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const data = new FormData();
data.append("file", file);
data.append("current_src", selectedImageSrc.value);
try {
const { data: res } = await window.axios.post(
route("admin.email-templates.replace-image", props.template.id),
data,
{ headers: { "Content-Type": "multipart/form-data" } }
);
const url = res?.url;
if (url) {
// Replace src in the iframe document
const iframe = iframeRef.value;
const doc = iframe?.contentDocument;
if (doc) {
// Update only the selected image if possible, else do a global replace
const imgs = Array.from(doc.querySelectorAll("img"));
const img = imgs.find(
(im) => (im.getAttribute("src") || "") === selectedImageSrc.value
);
if (img) {
img.setAttribute("src", url);
} else {
doc.body.innerHTML = doc.body.innerHTML.replaceAll(
selectedImageSrc.value,
url
);
}
// Sync back to the model
iframeSyncing = true;
form.html_template = doc.documentElement.outerHTML;
iframeSyncing = false;
selectedImageSrc.value = url;
}
if (documentId && res?.path) {
updateLocalDoc(documentId, res.path, null, res?.size ?? null);
}
}
} catch (e) {
console.error("Replace image failed", e);
}
};
input.click();
}
function setActive(field) {
activeField.value = field;
}
async function deleteAttachedImage(doc) {
if (!props.template?.id) return;
if (!doc?.id) return;
const confirmed = window.confirm(
"Odstranim to sliko iz predloge? Datoteka bo izbrisana."
);
if (!confirmed) return;
try {
await window.axios.delete(
route("admin.email-templates.images.delete", {
emailTemplate: props.template.id,
document: doc.id,
})
);
await removeLocalDoc(doc.id);
// After deletion, scrub or replace references in current HTML
tryReplaceOrRemoveDeletedImageReferences(doc);
} catch (e) {
console.error("Delete image failed", e);
alert("Brisanje slike ni uspelo.");
}
}
function tryReplaceOrRemoveDeletedImageReferences(deletedDoc) {
const deletedPath = deletedDoc?.path || "";
if (!deletedPath) return;
const targetRel = "/storage/" + deletedPath.replace(/^\/+/, "");
// Helper: does an <img> src match the deleted doc path (relative or absolute)?
const srcMatches = (src) => {
if (!src) return false;
try {
// Absolute URL: compare its pathname
const u = new URL(src, window.location.origin);
return u.pathname === targetRel;
} catch {
// Relative path string
if (src === targetRel) return true;
// Also accept raw disk path variant (unlikely in HTML)
if (src.replace(/^\/+/, "") === targetRel.replace(/^\/+/, "")) return true;
return false;
}
};
// Choose a replacement doc based on <img alt> when possible; else, if only one image remains, use that.
const pickReplacement = (altText) => {
const remaining = (docsRaw.value || []).slice();
if (!remaining.length) return null;
const norm = (s) => (s || "").toString().toLowerCase();
const stem = (name) =>
(name || "")
.toString()
.toLowerCase()
.replace(/\.[^.]+$/, "");
const simplify = (s) => norm(s).replace(/[^a-z0-9]+/g, "");
if (altText) {
const altKey = simplify(altText);
// exact name stem match (name, file_name, original_name)
const exact = remaining.find((d) => {
const candidates = [d.name, d.file_name, d.original_name].map(stem);
return candidates.some(
(c) => simplify(c) === altKey || norm(c) === norm(altText)
);
});
if (exact) return exact;
// relaxed contains on simplified stems
const relaxed = remaining.find((d) => {
const candidates = [d.name, d.file_name, d.original_name].map(stem).map(simplify);
return candidates.some((c) => c && altKey && c.includes(altKey));
});
if (relaxed) return relaxed;
}
if (remaining.length === 1) return remaining[0];
return null;
};
const replaceInDocument = (docEl) => {
if (!docEl) return false;
let changed = false;
const imgs = Array.from(docEl.querySelectorAll("img"));
imgs.forEach((img) => {
const src = img.getAttribute("src");
if (!srcMatches(src)) return;
const alt = img.getAttribute("alt") || "";
const replacement = pickReplacement(alt);
if (replacement && replacement.path) {
img.setAttribute("src", "/storage/" + replacement.path.replace(/^\/+/, ""));
} else {
// No replacement remove the image tag entirely
img.parentNode && img.parentNode.removeChild(img);
}
changed = true;
});
return changed;
};
if (!rawMode.value) {
// Advanced iframe editor
const iframe = iframeRef.value;
const doc = iframe?.contentDocument;
if (doc && doc.documentElement) {
const changed = replaceInDocument(doc);
if (changed) {
iframeSyncing = true;
form.html_template = doc.documentElement.outerHTML;
iframeSyncing = false;
}
}
} else {
// Raw mode: parse and mutate via DOMParser
const html = form.html_template || "";
const full = ensureFullDoc(html);
const parser = new DOMParser();
const parsed = parser.parseFromString(full, "text/html");
const changed = replaceInDocument(parsed);
if (changed) {
form.html_template = parsed.documentElement.outerHTML;
}
}
}
function insertAtCursor(el, value, modelGetter, modelSetter) {
if (!el) return;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const current = modelGetter();
const next = current.slice(0, start) + value + current.slice(end);
modelSetter(next);
// restore caret after inserted value
requestAnimationFrame(() => {
el.focus();
const pos = start + value.length;
try {
el.setSelectionRange(pos, pos);
} catch {}
});
}
function insertPlaceholder(token) {
const content = `{{ ${token} }}`;
if (activeField.value === "subject" && subjectRef.value) {
insertAtCursor(
subjectRef.value,
content,
() => form.subject_template,
(v) => (form.subject_template = v)
);
} else if (activeField.value === "html") {
// If editing raw source, insert at caret into Monaco editor
if (rawMode.value && monacoEditor.value) {
const editor = monacoEditor.value;
const position = editor.getPosition();
const range = {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column,
};
editor.executeEdits("", [
{
range: range,
text: content,
forceMoveMarkers: true,
},
]);
editor.focus();
return;
}
// Otherwise, insert into the iframe at caret position
// Insert into the iframe at caret position without rewriting the document
const iframe = iframeRef.value;
const doc = iframe?.contentDocument;
if (doc) {
const sel = doc.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
const node = doc.createTextNode(content);
range.insertNode(node);
// place caret after inserted node
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
doc.body.appendChild(doc.createTextNode(content));
}
// Sync back to model
iframeSyncing = true;
form.html_template = doc.documentElement.outerHTML;
iframeSyncing = false;
} else {
// last resort
form.html_template = (form.html_template || "") + content;
}
} else if (activeField.value === "text" && textRef.value) {
insertAtCursor(
textRef.value,
content,
() => form.text_template,
(v) => (form.text_template = v)
);
}
}
// Quill handlers removed; image actions handled by iframe toolbar
const placeholderGroups = computed(() => {
const groups = [];
const want = new Set(form.entity_types || []);
const add = (key, label, tokens) => groups.push({ key, label, tokens });
if (want.has("person")) {
add("person", "Person", [
"person.first_name",
"person.last_name",
"person.full_name",
"person.email",
"person.phone",
]);
}
if (want.has("client")) {
add("client", "Client", ["client.id", "client.uuid", "client.person.full_name"]);
}
if (want.has("client_case")) {
add("case", "Case", [
"case.id",
"case.uuid",
"case.reference",
"case.person.full_name",
]);
}
if (want.has("contract")) {
add("contract", "Contract", [
"contract.id",
"contract.uuid",
"contract.reference",
"contract.amount",
"contract.meta.some_key",
]);
}
// Activity placeholders (always useful if template references workflow actions/decisions)
add("activity", "Activity", [
"activity.id",
"activity.note",
"activity.due_date",
"activity.amount",
"activity.action.name",
"activity.decision.name",
]);
// Extra is always useful for ad-hoc data
add("extra", "Extra", ["extra.some_key"]);
return groups;
});
// Sample entity selection for preview
const sample = ref({
client_id: "",
case_id: "",
contract_id: "",
activity_id: "",
extra: "",
});
// Cascading select options
const clients = ref([]);
const cases = ref([]);
const contracts = ref([]);
async function loadClients() {
const { data } = await window.axios.get(route("admin.email-templates.data.clients"));
clients.value = data;
}
async function loadCases(clientId) {
if (!clientId) {
cases.value = [];
return;
}
const { data } = await window.axios.get(
route("admin.email-templates.data.cases", clientId)
);
cases.value = data;
}
async function loadContracts(caseId) {
if (!caseId) {
contracts.value = [];
return;
}
const { data } = await window.axios.get(
route("admin.email-templates.data.contracts", caseId)
);
contracts.value = data;
}
watch(
() => sample.value.client_id,
(v) => {
sample.value.case_id = "";
sample.value.contract_id = "";
loadCases(v);
doPreview();
}
);
watch(
() => sample.value.case_id,
(v) => {
sample.value.contract_id = "";
loadContracts(v);
doPreview();
}
);
watch(
() => sample.value.contract_id,
() => doPreview()
);
watch(
() => sample.value.activity_id,
() => doPreview()
);
function applySample() {
fetchPreview();
loadClients();
}
function safeParseExtra(input) {
if (!input || typeof input !== "string") return undefined;
try {
const parsed = JSON.parse(input);
return typeof parsed === "object" && parsed !== null ? parsed : undefined;
} catch {
return undefined;
}
}
function sendTest() {
if (!props.template?.id) {
alert("Najprej shranite predlogo, nato pošljite test.");
return;
}
if (!sample.value.to) {
alert("Vnesite ciljni email naslov.");
return;
}
const payload = {
subject: form.subject_template,
html: form.html_template,
text: form.text_template,
activity_id: sample.value.activity_id || undefined,
client_id: sample.value.client_id || undefined,
person_id: sample.value.person_id || undefined,
case_id: sample.value.case_id || undefined,
contract_id: sample.value.contract_id || undefined,
extra: safeParseExtra(sample.value.extra),
to: sample.value.to,
embed: sendEmbedMode.value,
};
// Small helper for simple notifications without reloading
const notify = (message, _type = "info") => {
if (!message) {
return;
}
// Replace with your app's toast if available
alert(message);
};
router.post(route("admin.email-templates.send-test", props.template.id), payload, {
preserveScroll: true,
preserveState: true,
onSuccess: () => {
try {
const page = usePage();
const flash = page?.props?.flash ?? {};
if (flash.success) {
notify(flash.success, "success");
return;
}
if (flash.error) {
notify(flash.error, "error");
return;
}
} catch {}
notify("Testni e-poštni naslov je bil poslan.", "success");
// Slight UX tweak: controller now queues the email
// (flash success message from backend reflects 'queued')
},
onError: () => {
// Validation errors trigger onError; controller may also set flash('error') on redirect
try {
const page = usePage();
const flash = page?.props?.flash ?? {};
if (flash.error) {
notify(flash.error, "error");
return;
}
} catch {}
notify(
"Pri pošiljanju testnega e-poštnega sporočila je prišlo do napake.",
"error"
);
},
});
}
// Keep preview iframe in sync with server-rendered preview
watch(
() => preview.value?.html,
async () => {
await nextTick();
writePreviewDocument();
}
);
</script>
<template>
<AdminLayout :title="props.template ? 'Uredi predlogo' : 'Nova predloga'">
<Head :title="props.template ? 'Uredi predlogo' : 'Nova predloga'" />
<Card class="mb-6">
<CardHeader>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3">
<div
class="inline-flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 text-primary"
>
<FileTextIcon class="h-5 w-5" />
</div>
<div>
<CardTitle>{{
props.template ? "Uredi predlogo" : "Nova predloga"
}}</CardTitle>
<CardDescription> Konfiguracija e-poštne predloge </CardDescription>
</div>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" as-child>
<Link :href="route('admin.email-templates.index')">
<ArrowLeftIcon class="h-4 w-4" />
Nazaj
</Link>
</Button>
<Button @click="submit" :disabled="form.processing" size="sm">
<SaveIcon class="h-4 w-4" />
Shrani
</Button>
</div>
</div>
</CardHeader>
</Card>
<div class="space-y-6">
<!-- Editor card -->
<Card>
<CardHeader>
<div class="flex items-center gap-2">
<SettingsIcon class="h-5 w-5" />
<CardTitle class="text-base">Osnovna nastavitve</CardTitle>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="name">Ime</Label>
<Input
id="name"
v-model="form.name"
:class="{ 'border-destructive': form.errors.name }"
/>
<span v-if="form.errors.name" class="text-sm text-destructive">{{
form.errors.name
}}</span>
</div>
<div class="space-y-2">
<Label for="key">Ključ</Label>
<Input
id="key"
v-model="form.key"
:class="{ 'border-destructive': form.errors.key }"
/>
<span v-if="form.errors.key" class="text-sm text-destructive">{{
form.errors.key
}}</span>
</div>
</div>
<div class="space-y-2">
<Label for="entity_types">Tipi entitet</Label>
<div class="flex flex-wrap gap-3">
<div
v-for="et in ['person', 'client', 'client_case', 'contract']"
:key="et"
class="flex items-center gap-2"
>
<Checkbox
:id="'entity-' + et"
:default-value="form.entity_types.includes(et)"
@update:model-value="
(val) => {
if (val) {
if (!form.entity_types.includes(et)) form.entity_types.push(et);
} else {
form.entity_types = form.entity_types.filter((x) => x !== et);
}
}
"
/>
<Label :for="'entity-' + et" class="font-normal cursor-pointer">
{{ et }}
</Label>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-center gap-2">
<Switch
id="allow_attachments"
:default-value="form.allow_attachments"
@update:model-value="(val) => (form.allow_attachments = val)"
/>
<Label for="allow_attachments" class="font-normal cursor-pointer"
>Allow attachments</Label
>
</div>
<div class="flex items-center gap-2">
<Switch
id="active"
:default-value="form.active"
@update:model-value="(val) => (form.active = val)"
/>
<Label for="active" class="font-normal cursor-pointer">Aktivno</Label>
</div>
</div>
<Separator />
<div class="space-y-2">
<Label for="subject">Zadeva</Label>
<Input
id="subject"
ref="subjectRef"
v-model="form.subject_template"
@focus="setActive('subject')"
:class="{ 'border-destructive': form.errors.subject_template }"
/>
<span v-if="form.errors.subject_template" class="text-sm text-destructive">{{
form.errors.subject_template
}}</span>
</div>
<Separator />
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>HTML vsebina</Label>
<Button size="sm" variant="outline" @click="rawMode = !rawMode">
<CodeIcon class="h-4 w-4 mr-2" />
{{ rawMode ? "Vizualni urednik" : "HTML izvorna koda" }}
</Button>
</div>
<!-- Raw HTML source mode with Monaco Editor -->
<div
v-if="rawMode"
class="border rounded-md overflow-hidden"
style="min-height: 600px"
>
<VueMonacoEditor
v-model:value="form.html_template"
language="html"
:options="{
theme: 'vs-dark',
minimap: { enabled: false },
fontSize: 16,
lineHeight: 24,
lineNumbers: 'on',
wordWrap: 'on',
automaticLayout: true,
scrollBeyondLastLine: false,
formatOnPaste: true,
formatOnType: true,
padding: { top: 16, bottom: 16 },
}"
height="600px"
@mount="
(editor) => {
monacoEditor = editor;
setActive('html');
}
"
/>
</div>
<!-- Visual iframe editor -->
<div v-else class="space-y-2">
<div
class="flex flex-wrap items-center gap-1 p-2 bg-muted rounded-md border"
>
<Button
size="sm"
variant="ghost"
@click="iframeExec('bold')"
title="Bold"
>
<BoldIcon class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="iframeExec('italic')"
title="Italic"
>
<ItalicIcon class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="iframeExec('underline')"
title="Underline"
>
<UnderlineIcon class="h-4 w-4" />
</Button>
<Separator orientation="vertical" class="h-6" />
<Button
size="sm"
variant="ghost"
@click="iframeExec('justifyLeft')"
title="Align Left"
>
<AlignLeftIcon class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="iframeExec('justifyCenter')"
title="Align Center"
>
<AlignCenterIcon class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="iframeExec('justifyRight')"
title="Align Right"
>
<AlignRightIcon class="h-4 w-4" />
</Button>
<Separator orientation="vertical" class="h-6" />
<Button
size="sm"
variant="ghost"
@click="iframeExec('insertUnorderedList')"
title="Bullet List"
>
<ListIcon class="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
@click="iframeExec('insertOrderedList')"
title="Numbered List"
>
<ListOrderedIcon class="h-4 w-4" />
</Button>
<Separator orientation="vertical" class="h-6" />
<Button
size="sm"
variant="ghost"
@click="iframeInsertImage"
title="Insert Image"
>
<ImageIcon class="h-4 w-4" />
</Button>
<Button
v-if="selectedImageSrc"
size="sm"
variant="ghost"
@click="iframeReplaceSelectedImage"
title="Replace selected image"
>
<ImageIcon class="h-4 w-4" />
<span class="ml-1 text-xs">Zamenjaj</span>
</Button>
</div>
<iframe
ref="iframeRef"
class="w-full border rounded-md bg-white"
style="min-height: 400px"
@click="setActive('html')"
/>
</div>
</div>
<div class="space-y-2">
<Label for="text">Besedilna vsebina</Label>
<Textarea
id="text"
ref="textRef"
v-model="form.text_template"
@focus="setActive('text')"
rows="6"
/>
</div>
<Separator />
<div class="space-y-3">
<Label>Spremenljivke</Label>
<div class="space-y-2">
<div v-for="group in placeholderGroups" :key="group.key" class="space-y-2">
<div class="text-sm font-medium text-muted-foreground">
{{ group.label }}
</div>
<div class="flex flex-wrap gap-2">
<Button
v-for="token in group.tokens"
:key="token"
size="sm"
variant="outline"
@click="insertPlaceholder(token)"
class="font-mono text-xs"
>
<PlusCircleIcon class="h-3 w-3 mr-1" />
{{ token }}
</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Preview card -->
<Card>
<CardHeader>
<div class="flex items-center gap-2">
<EyeIcon class="h-5 w-5" />
<CardTitle class="text-base">Predogled</CardTitle>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="space-y-2">
<Label for="sample_client" class="flex items-center gap-1">
<UserIcon class="h-3 w-3" />
Stranka
</Label>
<Select v-model="sample.client_id">
<SelectTrigger id="sample_client">
<SelectValue placeholder="Izberi stranko" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Brez</SelectItem>
<SelectItem v-for="c in clients" :key="c.id" :value="c.id">
{{ c.person?.first_name }} {{ c.person?.last_name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="sample_case" class="flex items-center gap-1">
<FolderIcon class="h-3 w-3" />
Zadeva
</Label>
<Select v-model="sample.case_id" :disabled="!sample.client_id">
<SelectTrigger id="sample_case">
<SelectValue placeholder="Izberi zadevo" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Brez</SelectItem>
<SelectItem v-for="cs in cases" :key="cs.id" :value="cs.id">
{{ cs.reference || cs.id }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="sample_contract" class="flex items-center gap-1">
<BriefcaseIcon class="h-3 w-3" />
Pogodba
</Label>
<Select v-model="sample.contract_id" :disabled="!sample.case_id">
<SelectTrigger id="sample_contract">
<SelectValue placeholder="Izberi pogodbo" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Brez</SelectItem>
<SelectItem v-for="ct in contracts" :key="ct.id" :value="ct.id">
{{ ct.reference || ct.id }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="sample_activity" class="flex items-center gap-1">
<ActivityIcon class="h-3 w-3" />
Aktivnost
</Label>
<Input id="sample_activity" v-model="sample.activity_id" placeholder="ID" />
</div>
</div>
<div class="space-y-2">
<Label for="sample_extra">Dodatni podatki (JSON)</Label>
<Textarea
id="sample_extra"
v-model="sample.extra"
placeholder='{ "key": "value" }'
rows="2"
class="font-mono text-xs"
/>
</div>
<div class="flex items-center gap-2">
<Button size="sm" @click="applySample">
<EyeIcon class="h-4 w-4 mr-2" />
Osveži predogled
</Button>
<Button
v-if="props.template?.id"
size="sm"
variant="outline"
@click="openFinalHtmlDialog"
>
<Maximize2Icon class="h-4 w-4 mr-2" />
Končni HTML
</Button>
</div>
<Separator />
<div class="space-y-3">
<div class="space-y-1">
<Label class="text-sm">Zadeva</Label>
<div class="text-sm p-2 bg-muted rounded">
{{ preview.subject || "(prazno)" }}
</div>
</div>
<div class="space-y-1">
<Label class="text-sm">HTML</Label>
<iframe
ref="previewIframeRef"
class="w-full border rounded-md bg-white"
style="min-height: 320px"
/>
</div>
<div class="space-y-1">
<Label class="text-sm">Besedilo</Label>
<pre class="text-xs whitespace-pre-wrap bg-muted p-3 rounded-md">{{
preview.text || "(prazno)"
}}</pre>
</div>
</div>
<Separator />
<div class="space-y-3">
<Label>Pošlji testno sporočilo</Label>
<div class="flex gap-2">
<Input
v-model="sample.to"
placeholder="Email naslov prejemnika"
type="email"
class="flex-1"
/>
<Button @click="sendTest" :disabled="!props.template?.id || !sample.to">
<SendIcon class="h-4 w-4 mr-2" />
Pošlji
</Button>
</div>
</div>
</CardContent>
</Card>
<!-- Manage images panel -->
<Card>
<CardHeader>
<div class="flex items-center gap-2">
<ImageIcon class="h-5 w-5" />
<CardTitle class="text-base">Upravljanje slik</CardTitle>
</div>
</CardHeader>
<CardContent>
<div v-if="docs.length === 0" class="text-sm text-muted-foreground py-4">
Še ni naloženih slik.
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div
v-for="doc in docs"
:key="doc.id"
class="relative group border rounded-lg p-2 hover:bg-muted/50 transition-colors"
>
<div class="aspect-video bg-muted rounded overflow-hidden mb-2">
<img
v-if="doc.url"
:src="doc.url"
:alt="doc.name || 'Image'"
class="w-full h-full object-cover"
/>
</div>
<div class="text-xs truncate mb-1" :title="doc.name">{{ doc.name }}</div>
<div class="text-xs text-muted-foreground">{{ formatSize(doc.size) }}</div>
<Button
size="sm"
variant="destructive"
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
@click="deleteAttachedImage(doc)"
>
<Trash2Icon class="h-3 w-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</AdminLayout>
<!-- Final HTML dialog -->
<Dialog :open="showFinal" @update:open="(val) => (showFinal = val)">
<DialogContent class="max-w-5xl max-h-[84vh] flex flex-col">
<DialogHeader>
<DialogTitle>Končni HTML</DialogTitle>
<DialogDescription>
Popolnoma upodobljena e-poštna vsebina z vdelanimi slikami
</DialogDescription>
</DialogHeader>
<div class="flex-1 overflow-auto space-y-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Label for="embed-mode" class="text-sm">Način vdelanih slik:</Label>
<Select v-model="finalEmbedMode" @update:model-value="fetchFinalHtml">
<SelectTrigger id="embed-mode" class="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="base64">base64 (privzeto)</SelectItem>
<SelectItem value="hosted">hosted (povezava)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-2">
<Button size="sm" variant="outline" @click="copyFinalHtml">
<CopyIcon class="h-4 w-4 mr-2" />
Kopiraj
</Button>
<Button size="sm" variant="outline" @click="downloadFinalHtml">
<DownloadIcon class="h-4 w-4 mr-2" />
Prenesi
</Button>
</div>
</div>
<div v-if="finalAttachments.length > 0" class="space-y-2">
<Label class="text-sm">Priponke</Label>
<div class="space-y-1">
<div
v-for="(att, idx) in finalAttachments"
:key="idx"
class="text-xs p-2 bg-muted rounded flex items-center justify-between"
>
<span class="truncate">{{ att.filename }}</span>
<Badge variant="outline" class="ml-2">{{ formatSize(att.size) }}</Badge>
</div>
</div>
</div>
<div class="space-y-2">
<Label class="text-sm">Predogled</Label>
<iframe
:srcdoc="finalHtml"
class="w-full border rounded-md bg-white"
style="min-height: 400px"
/>
</div>
<div class="space-y-2">
<Label class="text-sm">Izvorna koda</Label>
<Textarea
:model-value="finalHtml"
readonly
rows="12"
class="font-mono text-xs"
/>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<style scoped>
.input {
width: 100%;
border-radius: 0.375rem;
border: 1px solid #d1d5db;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.input:focus {
outline: 2px solid transparent;
outline-offset: 2px;
border-color: #6366f1;
box-shadow: 0 0 0 1px #6366f1;
}
.label {
display: block;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.25rem;
}
</style>