All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 21s
Build and Push Docker Images / build-frontend (push) Successful in 1m11s
Build and Push Docker Images / build-website (push) Successful in 22s
Build and Push Docker Images / build-central-services-api (push) Successful in 21s
Build and Push Docker Images / build-central-services-admin (push) Successful in 21s
Build and Push Docker Images / build-docs (push) Successful in 21s
803 lines
29 KiB
Vue
803 lines
29 KiB
Vue
<script setup>
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
const auth = useAuthStore()
|
|
|
|
const emailData = ref({
|
|
to:"",
|
|
cc:null,
|
|
bcc: null,
|
|
subject: "",
|
|
html: "",
|
|
text: "",
|
|
account: "",
|
|
})
|
|
|
|
const emailAccounts = ref([])
|
|
const preloadedContent = ref("")
|
|
const loadedDocuments = ref([])
|
|
const loaded = ref(false)
|
|
const sending = ref(false)
|
|
const noAccountsPresent = ref(false)
|
|
const sourceMessage = ref(null)
|
|
const recipientOptions = ref([])
|
|
const selectedRecipient = ref(null)
|
|
const loadingRecipients = ref(false)
|
|
const preferredCustomerId = ref(null)
|
|
const showCc = ref(Boolean(route.query.cc))
|
|
const showBcc = ref(Boolean(route.query.bcc))
|
|
const attachmentInput = ref(null)
|
|
const selectedAttachmentFiles = ref([])
|
|
const composeMode = computed(() => {
|
|
const mode = String(route.query.mode || "")
|
|
return ["reply", "replyAll", "forward"].includes(mode) ? mode : "new"
|
|
})
|
|
const pageTitle = computed(() => ({
|
|
reply: "Antworten",
|
|
replyAll: "Allen antworten",
|
|
forward: "E-Mail weiterleiten",
|
|
}[composeMode.value] || "Neue E-Mail"))
|
|
const canSend = computed(() => Boolean(
|
|
String(emailData.value.to || "").trim()
|
|
&& String(emailData.value.subject || "").trim()
|
|
&& emailData.value.account
|
|
))
|
|
const attachmentCount = computed(() =>
|
|
selectedAttachmentFiles.value.length
|
|
+ loadedDocuments.value.length
|
|
+ (composeMode.value === "forward" ? sourceMessage.value?.attachments?.length || 0 : 0)
|
|
)
|
|
const attachmentLabel = computed(() => attachmentCount.value === 1
|
|
? "1 Anhang"
|
|
: `${attachmentCount.value} Anhänge`
|
|
)
|
|
|
|
const escapeHtml = (value = "") => String(value)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
|
|
const formatAddress = (address) => {
|
|
if (!address?.address) return ""
|
|
return address.name ? `"${String(address.name).replace(/"/g, "\\\"")}" <${address.address}>` : address.address
|
|
}
|
|
|
|
const formatAddressList = (addresses = []) => addresses.map(formatAddress).filter(Boolean).join(", ")
|
|
|
|
const normalizeEmail = (value = "") => {
|
|
const text = String(value).trim()
|
|
const bracketMatch = text.match(/<([^<>]+)>/)
|
|
return String(bracketMatch?.[1] || text.split(/[;,]/)[0] || "").trim().toLowerCase()
|
|
}
|
|
|
|
const parseInfoData = (value) => {
|
|
if (!value) return {}
|
|
if (typeof value === "object") return value
|
|
try {
|
|
return JSON.parse(value)
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
const addRecipientOption = (target, option) => {
|
|
const email = normalizeEmail(option.email)
|
|
if (!email) return
|
|
|
|
const existing = target.find(item => item.email === email)
|
|
if (existing) {
|
|
if (option.priority < existing.priority) Object.assign(existing, option, { email })
|
|
return
|
|
}
|
|
|
|
target.push({ ...option, email })
|
|
}
|
|
|
|
const syncSelectedRecipient = () => {
|
|
const currentValue = String(emailData.value.to || "").trim()
|
|
if (!currentValue) {
|
|
selectedRecipient.value = null
|
|
return
|
|
}
|
|
|
|
const currentEmail = normalizeEmail(currentValue)
|
|
const matchingOption = recipientOptions.value.find(option => option.email === currentEmail)
|
|
if (matchingOption && (currentValue.match(/@/g) || []).length === 1) {
|
|
selectedRecipient.value = matchingOption.value
|
|
return
|
|
}
|
|
|
|
const currentOption = {
|
|
label: `Aktuelle Empfänger · ${currentValue}`,
|
|
value: currentValue,
|
|
email: currentEmail,
|
|
priority: -2,
|
|
}
|
|
recipientOptions.value = [
|
|
currentOption,
|
|
...recipientOptions.value.filter(option => option.value !== currentValue),
|
|
]
|
|
selectedRecipient.value = currentValue
|
|
}
|
|
|
|
const loadRecipientOptions = async () => {
|
|
loadingRecipients.value = true
|
|
|
|
try {
|
|
const [customers, contacts] = await Promise.all([
|
|
useEntities("customers").select("*", "name", true),
|
|
useEntities("contacts").select("*, customer(id,name)", "fullName", true),
|
|
])
|
|
const options = []
|
|
const customerNames = new Map(customers.map(customer => [Number(customer.id), customer.name]))
|
|
|
|
customers
|
|
.filter(customer => customer.active !== false)
|
|
.forEach(customer => {
|
|
const infoData = parseInfoData(customer.infoData)
|
|
const isPreferred = Number(customer.id) === Number(preferredCustomerId.value)
|
|
const priority = isPreferred ? 0 : 20
|
|
|
|
addRecipientOption(options, {
|
|
email: infoData.email,
|
|
value: infoData.email,
|
|
label: `${isPreferred ? "Passender Kunde" : "Kunde"} · ${customer.name} · ${infoData.email || ""}`,
|
|
priority,
|
|
customerId: Number(customer.id),
|
|
kind: "customer",
|
|
})
|
|
addRecipientOption(options, {
|
|
email: infoData.invoiceEmail,
|
|
value: infoData.invoiceEmail,
|
|
label: `${isPreferred ? "Passender Kunde" : "Kunde"} · ${customer.name} · Rechnung · ${infoData.invoiceEmail || ""}`,
|
|
priority: priority + 1,
|
|
customerId: Number(customer.id),
|
|
kind: "invoice",
|
|
})
|
|
})
|
|
|
|
contacts
|
|
.filter(contact => contact.active !== false && contact.email)
|
|
.forEach(contact => {
|
|
const customerId = Number(contact.customer?.id || contact.customer || 0)
|
|
const customerName = contact.customer?.name || customerNames.get(customerId)
|
|
const contactName = contact.fullName
|
|
|| [contact.firstName, contact.lastName].filter(Boolean).join(" ")
|
|
|| contact.email
|
|
const isPreferred = customerId && customerId === Number(preferredCustomerId.value)
|
|
|
|
addRecipientOption(options, {
|
|
email: contact.email,
|
|
value: formatAddress({ name: contactName, address: contact.email }),
|
|
label: [
|
|
isPreferred ? "Passender Ansprechpartner" : "Ansprechpartner",
|
|
contactName,
|
|
customerName,
|
|
contact.email,
|
|
].filter(Boolean).join(" · "),
|
|
priority: isPreferred ? 2 : 30,
|
|
customerId,
|
|
kind: "contact",
|
|
})
|
|
})
|
|
|
|
if (!preferredCustomerId.value) {
|
|
const currentEmail = normalizeEmail(emailData.value.to)
|
|
preferredCustomerId.value = options.find(option => option.email === currentEmail)?.customerId || null
|
|
}
|
|
|
|
if (preferredCustomerId.value) {
|
|
options.forEach(option => {
|
|
if (Number(option.customerId) !== Number(preferredCustomerId.value)) return
|
|
option.priority = option.kind === "customer" ? 0 : option.kind === "invoice" ? 1 : 2
|
|
option.label = option.label
|
|
.replace(/^Kunde ·/, "Passender Kunde ·")
|
|
.replace(/^Ansprechpartner ·/, "Passender Ansprechpartner ·")
|
|
})
|
|
}
|
|
|
|
recipientOptions.value = options.sort((first, second) =>
|
|
first.priority - second.priority || first.label.localeCompare(second.label, "de")
|
|
)
|
|
syncSelectedRecipient()
|
|
} catch (err) {
|
|
recipientOptions.value = []
|
|
syncSelectedRecipient()
|
|
toast.add({
|
|
title: "Adressbuch konnte nicht geladen werden",
|
|
description: err?.data?.error || err?.message,
|
|
color: "warning",
|
|
})
|
|
} finally {
|
|
loadingRecipients.value = false
|
|
}
|
|
}
|
|
|
|
const selectRecipient = (value) => {
|
|
if (!value) return
|
|
emailData.value.to = value
|
|
}
|
|
|
|
const prefixedSubject = (prefix, subject = "") => {
|
|
const cleanSubject = String(subject || "")
|
|
return cleanSubject.toLowerCase().startsWith(prefix.toLowerCase()) ? cleanSubject : `${prefix} ${cleanSubject}`.trim()
|
|
}
|
|
|
|
const originalMessageBlock = (message, mode) => {
|
|
const sentAt = message.sentAt || message.receivedAt
|
|
const date = sentAt ? new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "full",
|
|
timeStyle: "short",
|
|
}).format(new Date(sentAt)) : ""
|
|
const originalText = escapeHtml(message.body?.text || message.preview || "").replace(/\n/g, "<br>")
|
|
|
|
if (mode === "forward") {
|
|
return `<hr><p><strong>Weitergeleitete Nachricht</strong><br>`
|
|
+ `Von: ${escapeHtml(formatAddressList(message.from))}<br>`
|
|
+ `Datum: ${escapeHtml(date)}<br>`
|
|
+ `Betreff: ${escapeHtml(message.subject || "(kein Betreff)")}<br>`
|
|
+ `An: ${escapeHtml(formatAddressList(message.to))}</p><blockquote>${originalText}</blockquote>`
|
|
}
|
|
|
|
return `<hr><blockquote><p>Am ${escapeHtml(date)} schrieb ${escapeHtml(formatAddressList(message.from))}:</p>${originalText}</blockquote>`
|
|
}
|
|
|
|
const setupPage = async () => {
|
|
//emailAccounts.value = await useEntities("emailAccounts").select()
|
|
|
|
emailAccounts.value = await useNuxtApp().$api("/api/email/accounts")
|
|
|
|
if(emailAccounts.value.length === 0) {
|
|
noAccountsPresent.value = true
|
|
} else {
|
|
emailData.value.account = emailAccounts.value[0].id
|
|
|
|
let initialContent = `<p></p><p></p><p></p>${auth.profile.email_signature || ""}`
|
|
|
|
if (route.query.source && composeMode.value !== "new") {
|
|
sourceMessage.value = await useNuxtApp().$api(`/api/email/messages/${route.query.source}`)
|
|
const sourceAccount = emailAccounts.value.find(account => account.id === sourceMessage.value.accountId)
|
|
if (sourceAccount) emailData.value.account = sourceAccount.id
|
|
|
|
if (composeMode.value === "forward") {
|
|
emailData.value.subject = prefixedSubject("Fw:", sourceMessage.value.subject)
|
|
} else {
|
|
const ownAddress = String(sourceAccount?.email || "").toLowerCase()
|
|
const replyRecipients = sourceMessage.value.replyTo?.length
|
|
? sourceMessage.value.replyTo
|
|
: sourceMessage.value.from || []
|
|
|
|
if (composeMode.value === "replyAll") {
|
|
const allRecipients = [
|
|
...replyRecipients,
|
|
...(sourceMessage.value.to || []),
|
|
...(sourceMessage.value.cc || []),
|
|
].filter(address => address?.address && address.address.toLowerCase() !== ownAddress)
|
|
const uniqueRecipients = Array.from(new Map(
|
|
allRecipients.map(address => [address.address.toLowerCase(), address])
|
|
).values())
|
|
emailData.value.to = formatAddressList(uniqueRecipients)
|
|
} else {
|
|
emailData.value.to = formatAddressList(replyRecipients)
|
|
}
|
|
emailData.value.subject = prefixedSubject("Re:", sourceMessage.value.subject)
|
|
}
|
|
|
|
initialContent += originalMessageBlock(sourceMessage.value, composeMode.value)
|
|
}
|
|
|
|
preloadedContent.value = initialContent
|
|
|
|
//Check Query
|
|
if(route.query.to) emailData.value.to = route.query.to
|
|
if(route.query.cc) emailData.value.cc = route.query.cc
|
|
if(route.query.bcc) emailData.value.bcc = route.query.bcc
|
|
if(route.query.subject) emailData.value.subject = route.query.subject
|
|
|
|
|
|
if(route.query.loadDocuments) {
|
|
const data = await useFiles().selectSomeDocuments(JSON.parse(route.query.loadDocuments))
|
|
|
|
if(data) loadedDocuments.value = data
|
|
|
|
loadedDocuments.value = await Promise.all(loadedDocuments.value.map(async doc => {
|
|
|
|
const document = await useEntities("createddocuments").selectSingle(doc.createddocument)
|
|
return {
|
|
...doc,
|
|
createddocument: document
|
|
}}))
|
|
|
|
if(loadedDocuments.value.length > 0) {
|
|
const createdDocument = loadedDocuments.value[0].createddocument
|
|
const customerId = Number(createdDocument.customer?.id || createdDocument.customer || 0) || null
|
|
const contactId = Number(createdDocument.contact?.id || createdDocument.contact || 0) || null
|
|
const customer = createdDocument.customer && typeof createdDocument.customer === "object"
|
|
? createdDocument.customer
|
|
: customerId
|
|
? await useEntities("customers").selectSingle(customerId)
|
|
: null
|
|
const contact = createdDocument.contact && typeof createdDocument.contact === "object"
|
|
? createdDocument.contact
|
|
: contactId
|
|
? await useEntities("contacts").selectSingle(contactId)
|
|
: null
|
|
|
|
preferredCustomerId.value = customerId || Number(contact?.customer?.id || contact?.customer || 0) || null
|
|
emailData.value.subject = `${createdDocument.title} von ${auth.activeTenantData.businessInfo.name}`
|
|
|
|
if(contact?.email) {
|
|
emailData.value.to = formatAddress({
|
|
name: contact.fullName,
|
|
address: contact.email,
|
|
})
|
|
} else if(parseInfoData(customer?.infoData).invoiceEmail) {
|
|
emailData.value.to = parseInfoData(customer.infoData).invoiceEmail
|
|
} else if(parseInfoData(customer?.infoData).email) {
|
|
emailData.value.to = parseInfoData(customer.infoData).email
|
|
}
|
|
}
|
|
}
|
|
|
|
await loadRecipientOptions()
|
|
loaded.value = true
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
setupPage()
|
|
|
|
const contentChanged = (content) => {
|
|
emailData.value.html = content.html
|
|
emailData.value.text = content.text
|
|
}
|
|
|
|
const fileKey = file => `${file.name}:${file.size}:${file.lastModified}`
|
|
|
|
const addAttachments = (files) => {
|
|
const attachments = [...selectedAttachmentFiles.value]
|
|
const existingKeys = new Set(attachments.map(fileKey))
|
|
|
|
Array.from(files || []).forEach(file => {
|
|
if (!existingKeys.has(fileKey(file))) {
|
|
attachments.push(file)
|
|
existingKeys.add(fileKey(file))
|
|
}
|
|
})
|
|
|
|
selectedAttachmentFiles.value = attachments
|
|
if (attachmentInput.value) attachmentInput.value.value = ""
|
|
}
|
|
|
|
const renderAttachments = event => addAttachments(event.target.files)
|
|
const dropAttachments = event => addAttachments(event.dataTransfer?.files)
|
|
const removeAttachment = index => selectedAttachmentFiles.value.splice(index, 1)
|
|
const removeLoadedDocument = index => loadedDocuments.value.splice(index, 1)
|
|
|
|
const formatFileSize = (bytes = 0) => {
|
|
if (!bytes) return "0 KB"
|
|
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`
|
|
return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`
|
|
}
|
|
|
|
|
|
|
|
const toBase64 = file => new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.readAsDataURL(file);
|
|
reader.onload = () => resolve(reader.result.split(",")[1]);
|
|
reader.onerror = reject;
|
|
});
|
|
|
|
function blobToBase64(blob) {
|
|
return new Promise((resolve, _) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result.split(",")[1]);
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
const sendEmail = async () => {
|
|
if (!canSend.value || sending.value) return
|
|
sending.value = true
|
|
|
|
try {
|
|
const body = {
|
|
...emailData.value,
|
|
attachments: [],
|
|
sourceMessageId: sourceMessage.value?.id,
|
|
composeMode: composeMode.value === "new" ? undefined : composeMode.value,
|
|
}
|
|
|
|
for await (const file of selectedAttachmentFiles.value) {
|
|
body.attachments.push({
|
|
filename: file.name,
|
|
content: await toBase64(file),
|
|
contentType: file.type,
|
|
encoding: "base64",
|
|
contentDisposition: "attachment"
|
|
})
|
|
}
|
|
|
|
for await (const doc of loadedDocuments.value) {
|
|
const res = await useFiles().downloadFile(doc.id, null, true)
|
|
|
|
body.attachments.push({
|
|
filename: doc.path.split("/").pop(),
|
|
content: await blobToBase64(res),
|
|
contentType: res.type,
|
|
encoding: "base64",
|
|
contentDisposition: "attachment"
|
|
})
|
|
}
|
|
|
|
const res = await useNuxtApp().$api("/api/email/send",{
|
|
method: "POST",
|
|
body,
|
|
})
|
|
|
|
if(!res.success) {
|
|
toast.add({title: "Fehler beim Absenden der E-Mail", color: "error"})
|
|
} else {
|
|
await navigateTo("/email")
|
|
toast.add({title: "E-Mail gesendet", color: "success"})
|
|
}
|
|
} catch (err) {
|
|
toast.add({
|
|
title: "Fehler beim Absenden der E-Mail",
|
|
description: err?.data?.error || err?.message,
|
|
color: "error",
|
|
})
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="noAccountsPresent" class="flex min-h-[70vh] items-center justify-center p-6">
|
|
<div class="max-w-md rounded-xl border border-(--ui-border) bg-(--ui-bg) p-8 text-center shadow-sm">
|
|
<div class="mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
<UIcon name="i-heroicons-envelope" class="size-6" />
|
|
</div>
|
|
<h1 class="text-xl font-semibold">Noch kein E-Mail-Konto eingerichtet</h1>
|
|
<p class="mt-2 text-sm text-dimmed">
|
|
Richte zuerst ein Konto ein, um E-Mails direkt aus FEDEO zu versenden.
|
|
</p>
|
|
<UButton
|
|
icon="i-heroicons-plus"
|
|
class="mt-5"
|
|
@click="router.push('/settings/emailaccounts')"
|
|
>
|
|
E-Mail-Konto einrichten
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
<div v-else>
|
|
<UProgress v-if="!loaded" animation="carousel" class="mx-auto mt-5 w-2/3" />
|
|
|
|
<div v-else>
|
|
<UDashboardNavbar :title="pageTitle">
|
|
<template #leading>
|
|
<UButton
|
|
icon="i-heroicons-arrow-left"
|
|
color="neutral"
|
|
variant="ghost"
|
|
aria-label="Zurück zum Postfach"
|
|
@click="router.push('/email')"
|
|
/>
|
|
</template>
|
|
<template #right>
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
@click="router.push('/email')"
|
|
>
|
|
Verwerfen
|
|
</UButton>
|
|
<UButton
|
|
icon="i-heroicons-paper-airplane"
|
|
@click="sendEmail"
|
|
:disabled="!canSend"
|
|
:loading="sending"
|
|
>
|
|
Senden
|
|
</UButton>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<main class="compose-scroll overflow-y-auto bg-(--ui-bg-muted)/40">
|
|
<div class="mx-auto flex w-full max-w-5xl flex-col gap-4 px-4 py-5 sm:px-6">
|
|
<div v-if="composeMode !== 'new'" class="flex items-center gap-2 text-sm text-dimmed">
|
|
<UBadge color="primary" variant="subtle">
|
|
{{ composeMode === 'forward' ? 'Weiterleitung' : composeMode === 'replyAll' ? 'Antwort an alle' : 'Antwort' }}
|
|
</UBadge>
|
|
<span class="truncate">{{ sourceMessage?.subject || 'Ursprüngliche E-Mail' }}</span>
|
|
</div>
|
|
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
<UIcon name="i-heroicons-user-group" class="size-5" />
|
|
</div>
|
|
<div>
|
|
<h2 class="font-semibold">Absender und Empfänger</h2>
|
|
<p class="text-xs text-dimmed">Versandkonto und Adressaten festlegen</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-5">
|
|
<UFormField label="Absender" required>
|
|
<USelectMenu
|
|
v-model="emailData.account"
|
|
:items="emailAccounts"
|
|
label-key="displayName"
|
|
value-key="id"
|
|
class="w-full"
|
|
/>
|
|
</UFormField>
|
|
|
|
<div>
|
|
<div class="mb-1.5 flex items-center justify-between gap-3">
|
|
<label class="text-sm font-medium">Empfänger <span class="text-error">*</span></label>
|
|
<div class="flex items-center gap-1">
|
|
<UButton
|
|
v-if="!showCc"
|
|
size="xs"
|
|
color="neutral"
|
|
variant="ghost"
|
|
@click="showCc = true"
|
|
>
|
|
CC
|
|
</UButton>
|
|
<UButton
|
|
v-if="!showBcc"
|
|
size="xs"
|
|
color="neutral"
|
|
variant="ghost"
|
|
@click="showBcc = true"
|
|
>
|
|
BCC
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid gap-2 lg:grid-cols-[minmax(280px,0.9fr)_minmax(320px,1.1fr)]">
|
|
<USelectMenu
|
|
v-model="selectedRecipient"
|
|
:items="recipientOptions"
|
|
value-key="value"
|
|
label-key="label"
|
|
:loading="loadingRecipients"
|
|
:search-input="{ placeholder: 'Kunde, Ansprechpartner oder E-Mail suchen' }"
|
|
placeholder="Adresse aus FEDEO wählen"
|
|
icon="i-heroicons-address-book"
|
|
class="w-full"
|
|
@update:model-value="selectRecipient"
|
|
/>
|
|
<UInput
|
|
v-model="emailData.to"
|
|
icon="i-heroicons-envelope"
|
|
placeholder="E-Mail-Adresse oder mehrere Adressen"
|
|
class="w-full"
|
|
@update:model-value="selectedRecipient = null"
|
|
/>
|
|
</div>
|
|
<p class="mt-1.5 text-xs text-dimmed">
|
|
Passende Adressen zum vorausgefüllten Kunden stehen oben. Mehrere Adressen mit Komma trennen.
|
|
</p>
|
|
</div>
|
|
|
|
<UFormField v-if="showCc" label="Kopie (CC)">
|
|
<div class="flex gap-2">
|
|
<UInput
|
|
v-model="emailData.cc"
|
|
icon="i-heroicons-envelope"
|
|
placeholder="E-Mail-Adressen für eine Kopie"
|
|
class="w-full"
|
|
/>
|
|
<UButton
|
|
icon="i-heroicons-x-mark"
|
|
color="neutral"
|
|
variant="ghost"
|
|
aria-label="CC ausblenden"
|
|
@click="emailData.cc = null; showCc = false"
|
|
/>
|
|
</div>
|
|
</UFormField>
|
|
|
|
<UFormField v-if="showBcc" label="Blindkopie (BCC)">
|
|
<div class="flex gap-2">
|
|
<UInput
|
|
v-model="emailData.bcc"
|
|
icon="i-heroicons-envelope"
|
|
placeholder="Verborgene Empfänger"
|
|
class="w-full"
|
|
/>
|
|
<UButton
|
|
icon="i-heroicons-x-mark"
|
|
color="neutral"
|
|
variant="ghost"
|
|
aria-label="BCC ausblenden"
|
|
@click="emailData.bcc = null; showBcc = false"
|
|
/>
|
|
</div>
|
|
</UFormField>
|
|
|
|
<UFormField label="Betreff" required>
|
|
<UInput
|
|
v-model="emailData.subject"
|
|
icon="i-heroicons-pencil-square"
|
|
placeholder="Worum geht es in dieser E-Mail?"
|
|
class="w-full"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
<UIcon name="i-heroicons-paper-clip" class="size-5" />
|
|
</div>
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
<h2 class="font-semibold">Anhänge</h2>
|
|
<UBadge v-if="attachmentCount" color="neutral" variant="subtle">{{ attachmentCount }}</UBadge>
|
|
</div>
|
|
<p class="text-xs text-dimmed">Dateien und FEDEO-Dokumente mitsenden</p>
|
|
</div>
|
|
</div>
|
|
<UButton
|
|
icon="i-heroicons-plus"
|
|
color="neutral"
|
|
variant="soft"
|
|
@click="attachmentInput?.click()"
|
|
>
|
|
Dateien auswählen
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
|
|
<input
|
|
ref="attachmentInput"
|
|
type="file"
|
|
multiple
|
|
class="hidden"
|
|
@change="renderAttachments"
|
|
>
|
|
|
|
<div
|
|
class="rounded-lg border border-dashed border-(--ui-border-accented) px-4 py-5 transition-colors hover:border-primary/60 hover:bg-primary/5"
|
|
@dragover.prevent
|
|
@drop.prevent="dropAttachments"
|
|
>
|
|
<div v-if="!attachmentCount" class="text-center">
|
|
<UIcon name="i-heroicons-arrow-up-tray" class="mx-auto size-7 text-dimmed" />
|
|
<p class="mt-2 text-sm font-medium">Dateien hier ablegen</p>
|
|
<p class="text-xs text-dimmed">oder über „Dateien auswählen“ hinzufügen</p>
|
|
</div>
|
|
|
|
<div v-else class="space-y-2">
|
|
<div
|
|
v-for="(file, index) in selectedAttachmentFiles"
|
|
:key="fileKey(file)"
|
|
class="flex items-center gap-3 rounded-md border border-(--ui-border) bg-(--ui-bg) px-3 py-2"
|
|
>
|
|
<UIcon name="i-heroicons-document" class="size-5 shrink-0 text-primary" />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-sm font-medium">{{ file.name }}</p>
|
|
<p class="text-xs text-dimmed">{{ formatFileSize(file.size) }}</p>
|
|
</div>
|
|
<UButton
|
|
icon="i-heroicons-x-mark"
|
|
size="xs"
|
|
color="neutral"
|
|
variant="ghost"
|
|
:aria-label="`${file.name} entfernen`"
|
|
@click="removeAttachment(index)"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-for="(doc, index) in loadedDocuments"
|
|
:key="`document-${doc.id}`"
|
|
class="flex items-center gap-3 rounded-md border border-(--ui-border) bg-(--ui-bg) px-3 py-2"
|
|
>
|
|
<UIcon name="i-heroicons-document-text" class="size-5 shrink-0 text-primary" />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-sm font-medium">
|
|
{{ doc.createddocument?.title || doc.path?.split('/').pop() || 'FEDEO-Dokument' }}
|
|
</p>
|
|
<p class="text-xs text-dimmed">
|
|
{{ doc.createddocument?.documentNumber ? `Dokument ${doc.createddocument.documentNumber}` : 'FEDEO-Dokument' }}
|
|
</p>
|
|
</div>
|
|
<UBadge color="primary" variant="subtle">Verknüpft</UBadge>
|
|
<UButton
|
|
icon="i-heroicons-x-mark"
|
|
size="xs"
|
|
color="neutral"
|
|
variant="ghost"
|
|
aria-label="FEDEO-Dokument entfernen"
|
|
@click="removeLoadedDocument(index)"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="composeMode === 'forward' && sourceMessage?.attachments?.length"
|
|
class="flex items-center gap-3 rounded-md border border-(--ui-border) bg-(--ui-bg) px-3 py-2"
|
|
>
|
|
<UIcon name="i-heroicons-arrow-uturn-right" class="size-5 shrink-0 text-primary" />
|
|
<p class="flex-1 text-sm">
|
|
{{ sourceMessage.attachments.length }} Originalanhang/Originalanhänge werden übernommen
|
|
</p>
|
|
<UBadge color="neutral" variant="subtle">Original</UBadge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
<UIcon name="i-heroicons-pencil" class="size-5" />
|
|
</div>
|
|
<div>
|
|
<h2 class="font-semibold">Nachricht</h2>
|
|
<p class="text-xs text-dimmed">Text der E-Mail bearbeiten</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<EmailTiptapEditor
|
|
@updateContent="contentChanged"
|
|
:preloadedContent="preloadedContent"
|
|
/>
|
|
</UCard>
|
|
|
|
<div class="sticky bottom-0 z-10 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-(--ui-border) bg-(--ui-bg)/95 px-4 py-3 shadow-lg backdrop-blur">
|
|
<div class="flex items-center gap-2 text-sm text-dimmed">
|
|
<UIcon name="i-heroicons-paper-clip" class="size-4" />
|
|
<span>{{ attachmentCount ? attachmentLabel : 'Keine Anhänge' }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<UButton color="neutral" variant="ghost" @click="router.push('/email')">
|
|
Verwerfen
|
|
</UButton>
|
|
<UButton
|
|
icon="i-heroicons-paper-airplane"
|
|
:disabled="!canSend"
|
|
:loading="sending"
|
|
@click="sendEmail"
|
|
>
|
|
E-Mail senden
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.compose-scroll {
|
|
height: calc(100vh - 64px);
|
|
}
|
|
</style>
|