Files
FEDEO/frontend/components/email/EmailEntityLinks.vue
flfeders 1890d1d97d
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 1m16s
Build and Push Docker Images / build-website (push) Successful in 23s
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 1m18s
feat(email): allow dismissing entity suggestions
2026-09-08 09:54:56 +02:00

278 lines
9.3 KiB
Vue

<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)
type Suggestion = Omit<EntityLink, "id"> & { confidence: "high" | "medium"; reason: string }
const suggestions = ref<Suggestion[]>([])
const loadingSuggestions = ref(false)
const suggestionError = ref("")
const dismissingSuggestion = ref("")
let suggestionRequest = 0
let active = true
onBeforeUnmount(() => { active = false; suggestionRequest++ })
const visibleSuggestions = computed(() => suggestions.value.filter(suggestion =>
!links.value.some(link => link.entityType === suggestion.entityType && link.entityId === suggestion.entityId),
))
async function loadSuggestions(refresh = false) {
const request = ++suggestionRequest
const messageId = props.messageId
loadingSuggestions.value = true
suggestionError.value = ""
try {
const response = await useNuxtApp().$api(`/api/email/messages/${messageId}/entity-suggestions`, {
method: "POST", body: { refresh },
})
if (request === suggestionRequest && messageId === props.messageId) suggestions.value = response.suggestions || []
} catch (err: any) {
if (request === suggestionRequest) suggestionError.value = err?.data?.error || "KI-Vorschläge konnten nicht geladen werden."
} finally {
if (request === suggestionRequest) loadingSuggestions.value = false
}
}
watch(() => props.messageId, () => {
suggestions.value = []
links.value = [...(props.entityLinks || [])]
loadSuggestions()
}, { immediate: true })
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(suggestion?: Suggestion) {
const entityId = suggestion?.entityId ?? selectedEntityId.value
const entityType = suggestion?.entityType ?? selectedEntityType.value
if (!entityId || saving.value) return
const messageId = props.messageId
saving.value = true
try {
const response = await useNuxtApp().$api(`/api/email/messages/${props.messageId}/entity-links`, {
method: "POST",
body: {
entityType,
entityId,
},
})
if (!active || messageId !== props.messageId) return
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 dismissSuggestion(suggestion: Suggestion) {
const key = `${suggestion.entityType}:${suggestion.entityId}`
const messageId = props.messageId
if (dismissingSuggestion.value) return
dismissingSuggestion.value = key
try {
await useNuxtApp().$api(
`/api/email/messages/${messageId}/entity-suggestions/${suggestion.entityType}/${suggestion.entityId}`,
{ method: "DELETE" },
)
if (!active || messageId !== props.messageId) return
suggestions.value = suggestions.value.filter(item =>
item.entityType !== suggestion.entityType || item.entityId !== suggestion.entityId,
)
} catch (err: any) {
toast.add({
title: "Ausblenden fehlgeschlagen",
description: err?.data?.error || err?.message,
color: "error",
})
} finally {
if (messageId === props.messageId) dismissingSuggestion.value = ""
}
}
async function removeLink(link: EntityLink) {
const messageId = props.messageId
saving.value = true
try {
const response = await useNuxtApp().$api(
`/api/email/messages/${props.messageId}/entity-links/${link.entityType}/${link.entityId}`,
{ method: "DELETE" },
)
if (!active || messageId !== props.messageId) return
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 border-t border-(--ui-border) pt-3" aria-live="polite">
<div class="flex items-center justify-between gap-2">
<span class="flex items-center gap-1.5 text-sm font-medium">
<UIcon name="i-heroicons-sparkles" class="size-4" /> KI-Zuordnungsvorschläge
</span>
<UButton size="xs" variant="ghost" color="neutral" :disabled="loadingSuggestions || saving" @click="loadSuggestions(true)">
Erneut prüfen
</UButton>
</div>
<p v-if="loadingSuggestions" class="mt-2 text-sm text-dimmed">Die KI prüft passende Zuordnungen </p>
<p v-else-if="suggestionError" class="mt-2 text-sm text-error">{{ suggestionError }}</p>
<template v-else>
<p v-if="!visibleSuggestions.length" class="mt-2 text-sm text-dimmed">Keine weiteren passenden Zuordnungen gefunden.</p>
<div v-for="suggestion in visibleSuggestions" :key="`${suggestion.entityType}:${suggestion.entityId}`"
class="mt-2 flex items-start justify-between gap-3 rounded-md bg-(--ui-bg) p-2.5">
<div class="min-w-0 text-sm">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ suggestion.entityTypeLabel }}: {{ suggestion.entityName }}</span>
<UBadge size="xs" variant="soft" :color="suggestion.confidence === 'high' ? 'success' : 'warning'">
{{ suggestion.confidence === 'high' ? 'Hohe Übereinstimmung' : 'Mögliche Zuordnung' }}
</UBadge>
</div>
<p class="mt-1 text-dimmed">{{ suggestion.reason }}</p>
</div>
<div class="flex shrink-0 items-center gap-1">
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-heroicons-eye-slash"
:loading="dismissingSuggestion === `${suggestion.entityType}:${suggestion.entityId}`"
:disabled="saving || Boolean(dismissingSuggestion)"
:aria-label="`${suggestion.entityName} ausblenden`"
@click="dismissSuggestion(suggestion)"
>
Ausblenden
</UButton>
<UButton size="xs" icon="i-heroicons-link" :disabled="saving || Boolean(dismissingSuggestion)" @click="addLink(suggestion)">Übernehmen</UButton>
</div>
</div>
</template>
</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>