Compare commits
3 Commits
b5419bb5ea
...
49b4c10e10
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b4c10e10 | |||
| f3384cfc74 | |||
| 4e4466ff11 |
27
backend/db/migrations/0066_email_entity_links.sql
Normal file
27
backend/db/migrations/0066_email_entity_links.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS "email_entity_links" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" bigint NOT NULL,
|
||||
"message_id" uuid NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" bigint NOT NULL,
|
||||
"linked_by" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_entity_links_tenant_id_tenants_id_fk"
|
||||
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
|
||||
ON DELETE cascade ON UPDATE cascade,
|
||||
CONSTRAINT "email_entity_links_message_id_email_messages_id_fk"
|
||||
FOREIGN KEY ("message_id") REFERENCES "public"."email_messages"("id")
|
||||
ON DELETE cascade ON UPDATE cascade,
|
||||
CONSTRAINT "email_entity_links_linked_by_auth_users_id_fk"
|
||||
FOREIGN KEY ("linked_by") REFERENCES "public"."auth_users"("id")
|
||||
ON DELETE set null ON UPDATE cascade
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "email_entity_links_message_entity_key"
|
||||
ON "email_entity_links" USING btree ("message_id", "entity_type", "entity_id");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "email_entity_links_entity_idx"
|
||||
ON "email_entity_links" USING btree ("tenant_id", "entity_type", "entity_id");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "email_entity_links_message_idx"
|
||||
ON "email_entity_links" USING btree ("message_id");
|
||||
@@ -442,6 +442,13 @@
|
||||
"when": 1788202715001,
|
||||
"tag": "0065_document_templates",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 63,
|
||||
"version": "7",
|
||||
"when": 1788799700000,
|
||||
"tag": "0066_email_entity_links",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -155,6 +155,39 @@ export const emailAttachments = pgTable(
|
||||
}),
|
||||
)
|
||||
|
||||
export const emailEntityLinks = pgTable(
|
||||
"email_entity_links",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
tenantId: bigint("tenant_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||
|
||||
messageId: uuid("message_id")
|
||||
.notNull()
|
||||
.references(() => emailMessages.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: bigint("entity_id", { mode: "number" }).notNull(),
|
||||
|
||||
linkedBy: uuid("linked_by")
|
||||
.references(() => authUsers.id, { onDelete: "set null", onUpdate: "cascade" }),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
messageEntityKey: uniqueIndex("email_entity_links_message_entity_key")
|
||||
.on(table.messageId, table.entityType, table.entityId),
|
||||
entityIdx: index("email_entity_links_entity_idx")
|
||||
.on(table.tenantId, table.entityType, table.entityId),
|
||||
messageIdx: index("email_entity_links_message_idx")
|
||||
.on(table.messageId),
|
||||
}),
|
||||
)
|
||||
|
||||
export const emailSyncState = pgTable(
|
||||
"email_sync_state",
|
||||
{
|
||||
@@ -204,5 +237,7 @@ export type EmailMessageBody = typeof emailMessageBodies.$inferSelect
|
||||
export type NewEmailMessageBody = typeof emailMessageBodies.$inferInsert
|
||||
export type EmailAttachment = typeof emailAttachments.$inferSelect
|
||||
export type NewEmailAttachment = typeof emailAttachments.$inferInsert
|
||||
export type EmailEntityLink = typeof emailEntityLinks.$inferSelect
|
||||
export type NewEmailEntityLink = typeof emailEntityLinks.$inferInsert
|
||||
export type EmailSyncState = typeof emailSyncState.$inferSelect
|
||||
export type NewEmailSyncState = typeof emailSyncState.$inferInsert
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import nodemailer from "nodemailer"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
|
||||
import { encrypt, decrypt } from "../utils/crypt"
|
||||
import { userCredentials } from "../../db/schema"
|
||||
import {
|
||||
customers,
|
||||
emailEntityLinks,
|
||||
emailMessages,
|
||||
plants,
|
||||
projects,
|
||||
userCredentials,
|
||||
vendors,
|
||||
} from "../../db/schema"
|
||||
import { emailSyncService } from "../modules/email/email.sync.service"
|
||||
|
||||
// @ts-ignore
|
||||
@@ -39,6 +47,53 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
|
||||
const bodyValue = (body: any, camelKey: string, snakeKey: string) => body[camelKey] ?? body[snakeKey]
|
||||
|
||||
const entityDefinitions = {
|
||||
customers: { table: customers, tenantColumn: customers.tenant, labelColumn: customers.name, label: "Kunde" },
|
||||
vendors: { table: vendors, tenantColumn: vendors.tenant, labelColumn: vendors.name, label: "Lieferant" },
|
||||
projects: { table: projects, tenantColumn: projects.tenant, labelColumn: projects.name, label: "Projekt" },
|
||||
plants: { table: plants, tenantColumn: plants.tenant, labelColumn: plants.name, label: "Objekt" },
|
||||
} as const
|
||||
|
||||
type EmailEntityType = keyof typeof entityDefinitions
|
||||
|
||||
const getEntityDefinition = (entityType: string) =>
|
||||
entityDefinitions[entityType as EmailEntityType] || null
|
||||
|
||||
const loadEntity = async (tenantId: number, entityType: string, entityId: number) => {
|
||||
const definition = getEntityDefinition(entityType)
|
||||
if (!definition) return null
|
||||
|
||||
const rows = await server.db
|
||||
.select({ id: definition.table.id, name: definition.labelColumn })
|
||||
.from(definition.table)
|
||||
.where(and(
|
||||
eq(definition.tenantColumn, tenantId),
|
||||
eq(definition.table.id, entityId),
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return rows[0] ? { ...rows[0], typeLabel: definition.label } : null
|
||||
}
|
||||
|
||||
const listMessageEntityLinks = async (tenantId: number, messageId: string) => {
|
||||
const links = await server.db
|
||||
.select()
|
||||
.from(emailEntityLinks)
|
||||
.where(and(
|
||||
eq(emailEntityLinks.tenantId, tenantId),
|
||||
eq(emailEntityLinks.messageId, messageId),
|
||||
))
|
||||
|
||||
return (await Promise.all(links.map(async (link) => {
|
||||
const entity = await loadEntity(tenantId, link.entityType, link.entityId)
|
||||
return entity ? {
|
||||
...link,
|
||||
entityName: entity.name,
|
||||
entityTypeLabel: entity.typeLabel,
|
||||
} : null
|
||||
}))).filter(Boolean)
|
||||
}
|
||||
|
||||
const applyDownloadCorsHeaders = (req: any, reply: any) => {
|
||||
const origin = req.headers.origin
|
||||
if (
|
||||
@@ -221,6 +276,8 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
html?: string
|
||||
attachments?: any
|
||||
account: string
|
||||
sourceMessageId?: string
|
||||
composeMode?: "reply" | "replyAll" | "forward"
|
||||
}
|
||||
|
||||
// Fetch email credentials
|
||||
@@ -248,6 +305,35 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
},
|
||||
})
|
||||
|
||||
const sourceMessage = body.sourceMessageId
|
||||
? await emailSync.getMessage(req.user.tenant_id, req.user.user_id, body.sourceMessageId)
|
||||
: null
|
||||
|
||||
if (body.sourceMessageId && !sourceMessage) {
|
||||
return reply.code(404).send({ error: "Ursprüngliche E-Mail nicht gefunden" })
|
||||
}
|
||||
|
||||
const attachments = [...(Array.isArray(body.attachments) ? body.attachments : [])]
|
||||
|
||||
if (body.composeMode === "forward" && sourceMessage?.attachments?.length) {
|
||||
for (const sourceAttachment of sourceMessage.attachments) {
|
||||
const attachment = await emailSync.getAttachmentContent(
|
||||
req.user.tenant_id,
|
||||
req.user.user_id,
|
||||
sourceAttachment.id,
|
||||
)
|
||||
if (!attachment) continue
|
||||
|
||||
attachments.push({
|
||||
filename: attachment.filename,
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
contentDisposition: "attachment",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isReply = body.composeMode === "reply" || body.composeMode === "replyAll"
|
||||
const message = {
|
||||
from: accountData.email,
|
||||
to: body.to,
|
||||
@@ -256,7 +342,9 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
subject: body.subject,
|
||||
html: body.html,
|
||||
text: body.text,
|
||||
attachments: body.attachments,
|
||||
attachments,
|
||||
inReplyTo: isReply ? sourceMessage?.messageId || undefined : undefined,
|
||||
references: isReply && sourceMessage?.messageId ? [sourceMessage.messageId] : undefined,
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail(message)
|
||||
@@ -381,13 +469,144 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id)
|
||||
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||
|
||||
return reply.send(message)
|
||||
return reply.send({
|
||||
...message,
|
||||
entityLinks: await listMessageEntityLinks(req.user.tenant_id, id),
|
||||
})
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht geladen werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/email/messages/:id/entity-links", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const body = (req.body || {}) as { entityType?: string; entityId?: number | string }
|
||||
const entityId = Number(body.entityId)
|
||||
|
||||
if (!body.entityType || !getEntityDefinition(body.entityType)) {
|
||||
return reply.code(400).send({ error: "Nicht unterstützter Entitätstyp" })
|
||||
}
|
||||
if (!Number.isSafeInteger(entityId) || entityId <= 0) {
|
||||
return reply.code(400).send({ error: "Ungültige Entitäts-ID" })
|
||||
}
|
||||
|
||||
const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id)
|
||||
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||
|
||||
const entity = await loadEntity(req.user.tenant_id, body.entityType, entityId)
|
||||
if (!entity) return reply.code(404).send({ error: "Entität nicht gefunden" })
|
||||
|
||||
await server.db
|
||||
.insert(emailEntityLinks)
|
||||
.values({
|
||||
tenantId: req.user.tenant_id,
|
||||
messageId: id,
|
||||
entityType: body.entityType,
|
||||
entityId,
|
||||
linkedBy: req.user.user_id,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
entityLinks: await listMessageEntityLinks(req.user.tenant_id, id),
|
||||
})
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht verknüpft werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.delete("/email/messages/:id/entity-links/:entityType/:entityId", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id, entityType, entityId: rawEntityId } = req.params as {
|
||||
id: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
}
|
||||
const entityId = Number(rawEntityId)
|
||||
|
||||
const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id)
|
||||
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||
|
||||
await server.db
|
||||
.delete(emailEntityLinks)
|
||||
.where(and(
|
||||
eq(emailEntityLinks.tenantId, req.user.tenant_id),
|
||||
eq(emailEntityLinks.messageId, id),
|
||||
eq(emailEntityLinks.entityType, entityType),
|
||||
eq(emailEntityLinks.entityId, entityId),
|
||||
))
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
entityLinks: await listMessageEntityLinks(req.user.tenant_id, id),
|
||||
})
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "Verknüpfung konnte nicht entfernt werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/email/entity-links/:entityType/:entityId", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { entityType, entityId: rawEntityId } = req.params as {
|
||||
entityType: string
|
||||
entityId: string
|
||||
}
|
||||
const entityId = Number(rawEntityId)
|
||||
|
||||
if (!getEntityDefinition(entityType)) {
|
||||
return reply.code(400).send({ error: "Nicht unterstützter Entitätstyp" })
|
||||
}
|
||||
if (!Number.isSafeInteger(entityId) || entityId <= 0) {
|
||||
return reply.code(400).send({ error: "Ungültige Entitäts-ID" })
|
||||
}
|
||||
if (!await loadEntity(req.user.tenant_id, entityType, entityId)) {
|
||||
return reply.code(404).send({ error: "Entität nicht gefunden" })
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select({
|
||||
message: emailMessages,
|
||||
linkId: emailEntityLinks.id,
|
||||
linkedAt: emailEntityLinks.createdAt,
|
||||
})
|
||||
.from(emailEntityLinks)
|
||||
.innerJoin(emailMessages, eq(emailMessages.id, emailEntityLinks.messageId))
|
||||
.where(and(
|
||||
eq(emailEntityLinks.tenantId, req.user.tenant_id),
|
||||
eq(emailEntityLinks.entityType, entityType),
|
||||
eq(emailEntityLinks.entityId, entityId),
|
||||
))
|
||||
.orderBy(desc(emailMessages.receivedAt), desc(emailMessages.sentAt))
|
||||
|
||||
return reply.send(rows.map((row) => ({
|
||||
...row.message,
|
||||
linkId: row.linkId,
|
||||
linkedAt: row.linkedAt,
|
||||
canOpen: row.message.userId === req.user.user_id,
|
||||
})))
|
||||
} catch (err: any) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: err.message || "Verknüpfte E-Mails konnten nicht geladen werden" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/email/messages/:id/read", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
|
||||
@@ -366,6 +366,11 @@ const invitePortalUser = async () => {
|
||||
v-else-if="tab.label === 'Zeiten'"
|
||||
:platform="platform"
|
||||
/>
|
||||
<EmailEntityMessages
|
||||
v-else-if="tab.label === 'E-Mails'"
|
||||
:entity-type="type"
|
||||
:entity-id="props.item.id"
|
||||
/>
|
||||
<div v-else-if="tab.label === 'Wiki'" class="h-[600px] w-full overflow-hidden">
|
||||
<WikiEntityWidget
|
||||
:entity-type="type"
|
||||
@@ -410,6 +415,11 @@ const invitePortalUser = async () => {
|
||||
@updateNeeded="emit('updateNeeded')"
|
||||
:platform="platform"
|
||||
/>
|
||||
<EmailEntityMessages
|
||||
v-else-if="sub.label === 'E-Mails'"
|
||||
:entity-type="type"
|
||||
:entity-id="props.item.id"
|
||||
/>
|
||||
<!--<EntityShowSubPhases
|
||||
:item="props.item"
|
||||
:top-level-type="type"
|
||||
|
||||
@@ -96,6 +96,26 @@ const togglePhasePanel = (phase) => {
|
||||
openPhaseKey.value = openPhaseKey.value === phase.key ? null : phase.key
|
||||
}
|
||||
|
||||
const getQuickActionTarget = (link) => {
|
||||
const [configuredPath, configuredQuery = ""] = String(link || "").split("?")
|
||||
const query = Object.fromEntries(new URLSearchParams(configuredQuery))
|
||||
let path = configuredPath
|
||||
|
||||
// Bereits gespeicherte Phasenvorlagen können noch die alten Routen enthalten.
|
||||
if (configuredPath === "/tasks/create") {
|
||||
path = "/tasks"
|
||||
query.mode = "create"
|
||||
} else if (configuredPath === "/events/edit") {
|
||||
path = "/standardEntity/events/create"
|
||||
}
|
||||
|
||||
new URLSearchParams(props.queryStringData || "").forEach((value, key) => {
|
||||
query[key] = value
|
||||
})
|
||||
|
||||
return { path, query }
|
||||
}
|
||||
|
||||
const changeActivePhase = async (key) => {
|
||||
console.log(props.item)
|
||||
let item = await useEntities("projects").selectSingle(props.item.id,'*')
|
||||
@@ -175,7 +195,7 @@ const changeActivePhase = async (key) => {
|
||||
<UButton
|
||||
v-if="item.active"
|
||||
v-for="button in item.quickactions"
|
||||
@click="router.push(`${button.link}&${props.queryStringData}`)"
|
||||
@click="router.push(getQuickActionTarget(button.link))"
|
||||
>
|
||||
{{button.label}}
|
||||
</UButton>
|
||||
|
||||
169
frontend/components/email/EmailEntityLinks.vue
Normal file
169
frontend/components/email/EmailEntityLinks.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
type EntityLink = {
|
||||
id: string
|
||||
entityType: string
|
||||
entityId: number
|
||||
entityName: string
|
||||
entityTypeLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
messageId: string
|
||||
entityLinks?: EntityLink[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: [links: EntityLink[]]
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const links = ref<EntityLink[]>([])
|
||||
const selectedEntityType = ref("customers")
|
||||
const selectedEntityId = ref<number | null>(null)
|
||||
const entityOptions = ref<Array<{ label: string; value: number }>>([])
|
||||
const loadingOptions = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const entityTypes = [
|
||||
{ label: "Kunde", value: "customers" },
|
||||
{ label: "Lieferant", value: "vendors" },
|
||||
{ label: "Projekt", value: "projects" },
|
||||
{ label: "Objekt", value: "plants" },
|
||||
]
|
||||
|
||||
watch(() => props.entityLinks, (value) => {
|
||||
links.value = [...(value || [])]
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
async function loadEntityOptions() {
|
||||
loadingOptions.value = true
|
||||
selectedEntityId.value = null
|
||||
|
||||
try {
|
||||
const rows = await useEntities(selectedEntityType.value).select("*")
|
||||
entityOptions.value = rows.map((row: any) => {
|
||||
const number = row.customerNumber || row.vendorNumber || row.projectNumber
|
||||
return {
|
||||
label: number ? `${number} · ${row.name}` : row.name,
|
||||
value: Number(row.id),
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
entityOptions.value = []
|
||||
toast.add({
|
||||
title: "Entitäten konnten nicht geladen werden",
|
||||
description: err?.data?.error || err?.message,
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
loadingOptions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addLink() {
|
||||
if (!selectedEntityId.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await useNuxtApp().$api(`/api/email/messages/${props.messageId}/entity-links`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
entityType: selectedEntityType.value,
|
||||
entityId: selectedEntityId.value,
|
||||
},
|
||||
})
|
||||
links.value = response.entityLinks || []
|
||||
emit("updated", links.value)
|
||||
selectedEntityId.value = null
|
||||
toast.add({ title: "E-Mail verknüpft", color: "success" })
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Verknüpfen fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message,
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLink(link: EntityLink) {
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await useNuxtApp().$api(
|
||||
`/api/email/messages/${props.messageId}/entity-links/${link.entityType}/${link.entityId}`,
|
||||
{ method: "DELETE" },
|
||||
)
|
||||
links.value = response.entityLinks || []
|
||||
emit("updated", links.value)
|
||||
toast.add({ title: "Verknüpfung entfernt", color: "success" })
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Entfernen fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message,
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedEntityType, loadEntityOptions, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 rounded-lg border border-(--ui-border) bg-(--ui-bg-muted) p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium">Verknüpft mit</span>
|
||||
<UBadge
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
class="gap-1"
|
||||
>
|
||||
{{ link.entityTypeLabel }}: {{ link.entityName }}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 rounded hover:text-error"
|
||||
:disabled="saving"
|
||||
:aria-label="`Verknüpfung mit ${link.entityName} entfernen`"
|
||||
@click="removeLink(link)"
|
||||
>
|
||||
<UIcon name="i-heroicons-x-mark" class="size-3.5" />
|
||||
</button>
|
||||
</UBadge>
|
||||
<span v-if="!links.length" class="text-sm text-dimmed">Noch keine Zuordnung</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<USelectMenu
|
||||
v-model="selectedEntityType"
|
||||
:items="entityTypes"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
size="sm"
|
||||
class="w-36"
|
||||
/>
|
||||
<USelectMenu
|
||||
v-model="selectedEntityId"
|
||||
:items="entityOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
size="sm"
|
||||
class="min-w-64 flex-1"
|
||||
placeholder="Entität auswählen"
|
||||
:loading="loadingOptions"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-link"
|
||||
size="sm"
|
||||
:loading="saving"
|
||||
:disabled="!selectedEntityId"
|
||||
@click="addLink"
|
||||
>
|
||||
Verknüpfen
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
129
frontend/components/email/EmailEntityMessages.vue
Normal file
129
frontend/components/email/EmailEntityMessages.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
type EmailAddress = {
|
||||
name?: string | null
|
||||
address?: string | null
|
||||
}
|
||||
|
||||
type LinkedMessage = {
|
||||
id: string
|
||||
accountId: string
|
||||
mailboxPath: string
|
||||
subject?: string | null
|
||||
from?: EmailAddress[] | null
|
||||
to?: EmailAddress[] | null
|
||||
preview?: string | null
|
||||
receivedAt?: string | null
|
||||
sentAt?: string | null
|
||||
linkedAt: string
|
||||
hasAttachments: boolean
|
||||
canOpen: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: string
|
||||
entityId: string | number
|
||||
}>()
|
||||
|
||||
const messages = ref<LinkedMessage[]>([])
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref("")
|
||||
|
||||
const formatAddress = (addresses?: EmailAddress[] | null) => {
|
||||
const address = addresses?.[0]
|
||||
if (!address) return "Unbekannt"
|
||||
return address.name || address.address || "Unbekannt"
|
||||
}
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return ""
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
const openMessage = (message: LinkedMessage) => navigateTo({
|
||||
path: "/email",
|
||||
query: {
|
||||
account: message.accountId,
|
||||
mailbox: message.mailboxPath,
|
||||
message: message.id,
|
||||
},
|
||||
})
|
||||
|
||||
async function loadMessages() {
|
||||
loading.value = true
|
||||
errorMessage.value = ""
|
||||
|
||||
try {
|
||||
messages.value = await useNuxtApp().$api(
|
||||
`/api/email/entity-links/${props.entityType}/${props.entityId}`,
|
||||
)
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err?.data?.error || err?.message || "Verknüpfte E-Mails konnten nicht geladen werden."
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.entityType, props.entityId], loadMessages, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div v-if="loading" class="space-y-3">
|
||||
<USkeleton v-for="index in 4" :key="index" class="h-28" />
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-else-if="errorMessage"
|
||||
color="error"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
title="E-Mails konnten nicht geladen werden"
|
||||
:description="errorMessage"
|
||||
/>
|
||||
|
||||
<TableEmptyState
|
||||
v-else-if="!messages.length"
|
||||
label="Noch keine E-Mails mit dieser Entität verknüpft"
|
||||
/>
|
||||
|
||||
<article
|
||||
v-for="message in messages"
|
||||
v-else
|
||||
:key="message.id"
|
||||
class="rounded-lg border border-(--ui-border) bg-(--ui-bg) p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate font-medium text-highlighted">
|
||||
{{ message.subject || '(kein Betreff)' }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-dimmed">
|
||||
Von {{ formatAddress(message.from) }} · {{ formatDate(message.receivedAt || message.sentAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="message.canOpen"
|
||||
icon="i-heroicons-arrow-top-right-on-square"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
@click="openMessage(message)"
|
||||
>
|
||||
Im Postfach öffnen
|
||||
</UButton>
|
||||
</div>
|
||||
<p class="mt-3 line-clamp-2 text-sm text-muted">
|
||||
{{ message.preview || 'Keine Vorschau verfügbar' }}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-3 text-xs text-dimmed">
|
||||
<span>Verknüpft am {{ formatDate(message.linkedAt) }}</span>
|
||||
<span v-if="message.hasAttachments" class="flex items-center gap-1">
|
||||
<UIcon name="i-heroicons-paper-clip" class="size-3.5" />
|
||||
Anhänge
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,9 +52,17 @@ type EmailMessage = {
|
||||
contentType?: string | null
|
||||
size?: number | null
|
||||
}>
|
||||
entityLinks?: Array<{
|
||||
id: string
|
||||
entityType: string
|
||||
entityId: number
|
||||
entityName: string
|
||||
entityTypeLabel: string
|
||||
}>
|
||||
}
|
||||
|
||||
const { $api } = useNuxtApp()
|
||||
const route = useRoute()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -74,6 +82,7 @@ const expandedMailboxPaths = ref<string[]>([])
|
||||
const syncedMailboxPaths = ref<string[]>([])
|
||||
const actionLoading = ref("")
|
||||
const moveTargetMailboxPath = ref("")
|
||||
let deepLinkApplied = false
|
||||
|
||||
const selectedAccount = computed(() =>
|
||||
accounts.value.find((account) => account.id === selectedAccountId.value) || null
|
||||
@@ -295,7 +304,10 @@ async function loadAccounts() {
|
||||
loadingAccounts.value = true
|
||||
try {
|
||||
accounts.value = await $api("/api/email/accounts")
|
||||
selectedAccountId.value = accounts.value[0]?.id || ""
|
||||
const requestedAccountId = String(route.query.account || "")
|
||||
selectedAccountId.value = accounts.value.some((account) => account.id === requestedAccountId)
|
||||
? requestedAccountId
|
||||
: accounts.value[0]?.id || ""
|
||||
if (selectedAccountId.value) {
|
||||
await loadMailboxes()
|
||||
}
|
||||
@@ -316,7 +328,9 @@ async function loadMailboxes() {
|
||||
resetExpandedMailboxes()
|
||||
const inbox = mailboxes.value.find((mailbox) => mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX")
|
||||
const previousMailbox = mailboxes.value.find((mailbox) => mailbox.path === previousMailboxPath)
|
||||
selectedMailboxPath.value = previousMailbox?.path || inbox?.path || mailboxes.value[0]?.path || "INBOX"
|
||||
const requestedMailboxPath = String(route.query.mailbox || "")
|
||||
const requestedMailbox = mailboxes.value.find((mailbox) => mailbox.path === requestedMailboxPath)
|
||||
selectedMailboxPath.value = requestedMailbox?.path || previousMailbox?.path || inbox?.path || mailboxes.value[0]?.path || "INBOX"
|
||||
expandMailboxAncestors(selectedMailboxPath.value)
|
||||
await loadMessages()
|
||||
} finally {
|
||||
@@ -340,7 +354,10 @@ async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
|
||||
}
|
||||
|
||||
if (messages.value.length) {
|
||||
await selectMessage(messages.value[0])
|
||||
const requestedMessageId = deepLinkApplied ? "" : String(route.query.message || "")
|
||||
const requestedMessage = messages.value.find((message) => message.id === requestedMessageId)
|
||||
await selectMessage(requestedMessage || messages.value[0])
|
||||
deepLinkApplied = true
|
||||
}
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
@@ -375,6 +392,22 @@ async function selectMessage(message: EmailMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectedMessageEntityLinks(entityLinks: NonNullable<EmailMessage["entityLinks"]>) {
|
||||
if (!selectedMessage.value) return
|
||||
selectedMessage.value = { ...selectedMessage.value, entityLinks }
|
||||
}
|
||||
|
||||
function openComposer(mode: "reply" | "replyAll" | "forward") {
|
||||
if (!selectedMessage.value) return
|
||||
navigateTo({
|
||||
path: "/email/new",
|
||||
query: {
|
||||
mode,
|
||||
source: selectedMessage.value.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function removeMessageFromCurrentList(messageId: string) {
|
||||
const currentIndex = messages.value.findIndex((message) => message.id === messageId)
|
||||
messages.value = messages.value.filter((message) => message.id !== messageId)
|
||||
@@ -800,16 +833,26 @@ onMounted(loadAccounts)
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
@click="navigateTo(`/email/new?to=${encodeURIComponent(formatAddressList(selectedMessage.from))}&subject=${encodeURIComponent(`Re: ${selectedMessage.subject || ''}`)}`)"
|
||||
@click="openComposer('reply')"
|
||||
>
|
||||
Antworten
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="selectedMessage.cc?.length || (selectedMessage.to?.length || 0) > 1"
|
||||
icon="i-heroicons-users"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="openComposer('replyAll')"
|
||||
>
|
||||
Allen antworten
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-uturn-right"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="navigateTo(`/email/new?subject=${encodeURIComponent(`Fw: ${selectedMessage.subject || ''}`)}`)"
|
||||
@click="openComposer('forward')"
|
||||
>
|
||||
Weiterleiten
|
||||
</UButton>
|
||||
@@ -831,6 +874,12 @@ onMounted(loadAccounts)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EmailEntityLinks
|
||||
:message-id="selectedMessage.id"
|
||||
:entity-links="selectedMessage.entityLinks"
|
||||
@updated="updateSelectedMessageEntityLinks"
|
||||
/>
|
||||
|
||||
<div v-if="selectedMessage.attachments?.length" class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="attachment in selectedMessage.attachments"
|
||||
|
||||
@@ -21,6 +21,55 @@ const preloadedContent = ref("")
|
||||
const loadedDocuments = ref([])
|
||||
const loaded = ref(false)
|
||||
const noAccountsPresent = ref(false)
|
||||
const sourceMessage = ref(null)
|
||||
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 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 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()
|
||||
|
||||
@@ -31,7 +80,41 @@ const setupPage = async () => {
|
||||
} else {
|
||||
emailData.value.account = emailAccounts.value[0].id
|
||||
|
||||
preloadedContent.value = `<p></p><p></p><p></p>${auth.profile.email_signature || ""}`
|
||||
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
|
||||
@@ -120,7 +203,9 @@ const sendEmail = async () => {
|
||||
|
||||
let body = {
|
||||
...emailData.value,
|
||||
attachments: []
|
||||
attachments: [],
|
||||
sourceMessageId: sourceMessage.value?.id,
|
||||
composeMode: composeMode.value === "new" ? undefined : composeMode.value,
|
||||
}
|
||||
|
||||
|
||||
@@ -151,25 +236,27 @@ const sendEmail = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
console.log(body)
|
||||
|
||||
try {
|
||||
const res = await useNuxtApp().$api("/api/email/send",{
|
||||
method: "POST",
|
||||
body: body,
|
||||
})
|
||||
|
||||
console.log(res)
|
||||
|
||||
|
||||
if(!res.success) {
|
||||
toast.add({title: "Fehler beim Absenden der E-Mail", color: "error"})
|
||||
|
||||
} else {
|
||||
navigateTo("/")
|
||||
toast.add({title: "E-Mail zum Senden eingereiht"})
|
||||
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 {
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -192,7 +279,7 @@ const sendEmail = async () => {
|
||||
|
||||
<div v-else>
|
||||
<UDashboardNavbar
|
||||
title="Neue E-Mail"
|
||||
:title="pageTitle"
|
||||
>
|
||||
<template #right>
|
||||
<UButton
|
||||
@@ -280,6 +367,12 @@ const sendEmail = async () => {
|
||||
>
|
||||
<span v-if="doc.createddocument">Dokument - {{doc.createddocument.documentNumber}}</span>
|
||||
</li>
|
||||
<li
|
||||
v-if="composeMode === 'forward' && sourceMessage?.attachments?.length"
|
||||
class="list-disc"
|
||||
>
|
||||
{{ sourceMessage.attachments.length }} Originalanhang/Originalanhänge werden übernommen
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -279,10 +279,10 @@ const addPhase = () => {
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Rechnung',link:'/createDocument/edit/?type=invoices'})">Rechnung Erstellen</UButton>
|
||||
<UButton
|
||||
class="my-1"
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Aufgabe',link:'/tasks/create'})">Aufgabe Erstellen</UButton>
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Aufgabe',link:'/tasks?mode=create'})">Aufgabe Erstellen</UButton>
|
||||
<UButton
|
||||
class="my-1"
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Termin',link:'/events/edit'})">Termin Erstellen</UButton>
|
||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Termin',link:'/standardEntity/events/create'})">Termin Erstellen</UButton>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -494,7 +494,7 @@ export const useDataStore = defineStore('data', () => {
|
||||
inputColumn: "Allgemeines"
|
||||
},*/
|
||||
],
|
||||
showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Ausgehende SEPA-Mandate', key: 'outgoingsepamandates', type: 'outgoingsepamandates'},{label: 'Kundeninventar', key: 'customerinventoryitems'},{label: 'Kundenlagerplätze', key: 'customerspaces'},{label: 'Wiki'}]
|
||||
showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'E-Mails'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Ausgehende SEPA-Mandate', key: 'outgoingsepamandates', type: 'outgoingsepamandates'},{label: 'Kundeninventar', key: 'customerinventoryitems'},{label: 'Kundenlagerplätze', key: 'customerspaces'},{label: 'Wiki'}]
|
||||
},
|
||||
members: {
|
||||
isArchivable: true,
|
||||
@@ -1463,6 +1463,8 @@ export const useDataStore = defineStore('data', () => {
|
||||
label: "Projekte"
|
||||
},{
|
||||
label: "Aufgaben"
|
||||
},{
|
||||
label: "E-Mails"
|
||||
},{
|
||||
label: "Dateien"
|
||||
},{
|
||||
@@ -1741,6 +1743,8 @@ export const useDataStore = defineStore('data', () => {
|
||||
},{
|
||||
key: "tasks",
|
||||
label: "Aufgaben"
|
||||
},{
|
||||
label: "E-Mails"
|
||||
},{
|
||||
key: "files",
|
||||
label: "Dateien"
|
||||
@@ -2024,6 +2028,8 @@ export const useDataStore = defineStore('data', () => {
|
||||
label: 'Informationen',
|
||||
},{
|
||||
label: 'Ansprechpartner',
|
||||
}, {
|
||||
label: 'E-Mails',
|
||||
}, {
|
||||
label: 'Dateien',
|
||||
}, {
|
||||
@@ -3300,8 +3306,8 @@ export const useDataStore = defineStore('data', () => {
|
||||
label: "Beteiligte Benutzer",
|
||||
inputType: "select",
|
||||
selectDataType: "profiles",
|
||||
selectOptionAttribute: "fullName",
|
||||
selectSearchAttributes: ['fullName'],
|
||||
selectOptionAttribute: "full_name",
|
||||
selectSearchAttributes: ['full_name'],
|
||||
selectMultiple: true,
|
||||
component: profiles
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user