feat(documents): prepare inbox suggestions and add entity assignments
All checks were successful
Build and Release FEDEO / verify (push) Successful in 56s
Build and Release FEDEO / build-and-release (push) Successful in 5m58s

This commit is contained in:
2026-10-02 16:09:04 +02:00
parent 64299843e2
commit d7382e7319
15 changed files with 707 additions and 72 deletions

View File

@@ -1,18 +1,21 @@
<script setup lang="ts">
import dayjs from "dayjs"
type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: string }
type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: string; suggestionStatus?: string }
type ScannerConnection = { username: string; active: boolean; password?: string }
type FilingFolder = { id: string; name: string; parent: string | null; standardFiletype: string | null; standardFiletypeIsOptional: boolean }
type FilingType = { id: string; name: string }
type FilingSuggestion = { folderId: string | null; typeId: string; confidence: "high" | "medium"; reason: string }
type SuggestionResult = { suggestion: FilingSuggestion | null; message?: string }
type SuggestionResult = { status: "pending" | "processing" | "ready" | "failed"; suggestion: FilingSuggestion | null; message?: string }
type AssignmentKind = { value: string; label: string }
type Assignment = { kind: string; id: string; name: string; available?: boolean }
const { $api } = useNuxtApp()
const auth = useAuthStore()
const config = useRuntimeConfig()
const fileActions = useFiles()
const toast = useToast()
const inboxCount = useDocumentInboxCount()
const documents = ref<InboxDocument[]>([])
const total = ref(0)
const page = ref(1)
@@ -43,9 +46,22 @@ const suggestionLoading = ref(false)
const suggestionError = ref("")
const suggestionMessage = ref("")
const selectionTouched = ref(false)
const suggestionCache = new Map<string, SuggestionResult>()
const suggestionStatus = ref("")
let suggestionTimer: ReturnType<typeof setTimeout> | undefined
let suggestionController: AbortController | undefined
let suggestionVersion = 0
const assignmentKinds = ref<AssignmentKind[]>([])
const assignments = ref<Assignment[]>([])
const assignmentChanges = ref<Record<string, string | null>>({})
const assignmentKind = ref("project")
const assignmentSearch = ref("")
const assignmentOptions = ref<{ id: string; name: string }[]>([])
const assignmentId = ref<string | null>(null)
const assignmentLoading = ref(false)
const assignmentError = ref("")
let assignmentVersion = 0
let assignmentTimer: ReturnType<typeof setTimeout> | undefined
let assignmentController: AbortController | undefined
const selectedFolder = computed(() => filingFolders.value.find(folder => folder.id === filingFolder.value))
const fixedType = computed(() => Boolean(selectedFolder.value?.standardFiletype && !selectedFolder.value.standardFiletypeIsOptional))
const folderOptions = computed(() => {
@@ -125,8 +141,14 @@ const applySuggestion = () => {
const loadFilingSuggestion = async (refresh = false) => {
const entry = filingDocument.value
if (!entry || filingLoading.value || filingSaving.value || suggestionLoading.value) return
if (!entry || disposed || filingLoading.value || suggestionLoading.value) return
if (filingSaving.value) {
clearTimeout(suggestionTimer)
suggestionTimer = setTimeout(() => loadFilingSuggestion(), 2000)
return
}
const version = ++suggestionVersion
clearTimeout(suggestionTimer)
const filingRequest = filingVersion
suggestionController?.abort()
suggestionController = new AbortController()
@@ -134,18 +156,28 @@ const loadFilingSuggestion = async (refresh = false) => {
suggestionError.value = ""
suggestionMessage.value = ""
try {
const result = (!refresh && suggestionCache.get(entry.id)) || await $api<SuggestionResult>(`/api/document-inbox/${entry.id}/suggestion`, {
method: "POST", signal: suggestionController.signal,
const result = await $api<SuggestionResult>(`/api/document-inbox/${entry.id}/suggestion`, {
method: refresh ? "POST" : "GET", signal: suggestionController.signal,
})
if (disposed || version !== suggestionVersion || filingRequest !== filingVersion) return
suggestionStatus.value = result.status
if (["pending", "processing"].includes(result.status)) {
suggestion.value = null
suggestionMessage.value = result.status === "pending" ? "KI-Vorschlag wird im Hintergrund vorbereitet …" : "Der Dokumentinhalt wird geprüft …"
suggestionTimer = setTimeout(() => loadFilingSuggestion(), 2000)
return
}
if (result.status === "failed") {
suggestion.value = null
suggestionError.value = result.message || "Die KI-Vorbereitung ist fehlgeschlagen. Bitte erneut prüfen oder manuell zuordnen."
return
}
const proposed = result.suggestion
const folder = filingFolders.value.find(folder => folder.id === proposed?.folderId)
const valid = proposed && filingTypes.value.some(type => type.id === proposed.typeId)
&& (proposed.folderId === null || (folder && (!folder.standardFiletype || folder.standardFiletypeIsOptional || folder.standardFiletype === proposed.typeId)))
suggestion.value = valid ? proposed : null
suggestionMessage.value = valid ? "" : result.message || "Keine passende Zuordnung gefunden. Du kannst Ordner und Typ selbst auswählen."
if (suggestionCache.size >= 50) suggestionCache.delete(suggestionCache.keys().next().value!)
suggestionCache.set(entry.id, { suggestion: suggestion.value, message: suggestionMessage.value })
// A delayed AI response must not replace choices the user has already made.
if (!selectionTouched.value) applySuggestion()
} catch (cause: any) {
@@ -156,15 +188,63 @@ const loadFilingSuggestion = async (refresh = false) => {
}
}
const loadAssignmentOptions = async () => {
if (!previewOpen.value || !assignmentKinds.value.some(kind => kind.value === assignmentKind.value)) return
const version = ++assignmentVersion
assignmentController?.abort()
assignmentController = new AbortController()
assignmentLoading.value = true
assignmentError.value = ""
assignmentId.value = null
assignmentOptions.value = []
try {
const options = await $api<{ id: string; name: string }[]>("/api/document-inbox/assignment-options", {
query: { kind: assignmentKind.value, search: assignmentSearch.value.trim() }, signal: assignmentController.signal,
})
if (!disposed && version === assignmentVersion) assignmentOptions.value = options
} catch {
if (!disposed && version === assignmentVersion) assignmentError.value = "Zuordnungen konnten nicht geladen werden. Bitte erneut versuchen."
} finally {
if (version === assignmentVersion) assignmentLoading.value = false
}
}
const addAssignment = () => {
if (filingSaving.value || assignmentLoading.value) return
const entry = assignmentOptions.value.find(option => option.id === assignmentId.value)
if (!entry) return
assignments.value = [...assignments.value.filter(item => item.kind !== assignmentKind.value), { kind: assignmentKind.value, ...entry }]
assignmentChanges.value[assignmentKind.value] = entry.id
assignmentId.value = null
}
const removeAssignment = (kind: string) => {
if (filingSaving.value) return
assignments.value = assignments.value.filter(item => item.kind !== kind)
assignmentChanges.value[kind] = null
}
const openPreview = (entry: InboxDocument) => openFiling(entry)
const openFiling = async (entry: InboxDocument) => {
suggestionVersion++
clearTimeout(suggestionTimer)
suggestionController?.abort()
suggestion.value = null
suggestionLoading.value = false
suggestionError.value = ""
suggestionMessage.value = ""
suggestionStatus.value = ""
assignmentVersion++
assignmentController?.abort()
clearTimeout(assignmentTimer)
assignments.value = []
assignmentChanges.value = {}
assignmentKinds.value = []
assignmentOptions.value = []
assignmentId.value = null
assignmentLoading.value = false
assignmentKind.value = "project"
assignmentSearch.value = ""
assignmentError.value = ""
selectionTouched.value = false
previewOpen.value = true
previewDocument.value = entry
@@ -178,10 +258,16 @@ const openFiling = async (entry: InboxDocument) => {
filingSaving.value = false
const version = ++filingVersion
try {
const result = await $api<{ folders: FilingFolder[]; types: FilingType[] }>("/api/document-inbox/filing-options")
const [result, current] = await Promise.all([
$api<{ folders: FilingFolder[]; types: FilingType[]; assignmentKinds: AssignmentKind[] }>("/api/document-inbox/filing-options"),
$api<Assignment[]>(`/api/document-inbox/${entry.id}/assignments`),
])
if (disposed || version !== filingVersion) return
filingFolders.value = result.folders
filingTypes.value = result.types
assignmentKinds.value = result.assignmentKinds || []
assignments.value = current
void loadAssignmentOptions()
} catch (cause: any) {
if (version === filingVersion) filingError.value = cause?.data?.error || cause?.data?.message || "Ordner und Dokumenttypen konnten nicht geladen werden."
} finally {
@@ -200,7 +286,7 @@ const fileDocument = async () => {
filingSaving.value = true
filingError.value = ""
try {
await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value } })
await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value, assignments: { ...assignmentChanges.value } } })
if (disposed || version !== filingVersion) return
// Cancel pending polling responses before removing the successfully filed entry.
requestVersion++
@@ -208,7 +294,7 @@ const fileDocument = async () => {
documents.value = documents.value.filter(document => document.id !== entry.id)
total.value = Math.max(0, total.value - 1)
page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize)))
suggestionCache.delete(entry.id)
void inboxCount.refresh()
previewOpen.value = false
toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" })
await load(true)
@@ -304,14 +390,35 @@ watch(filingFolder, () => {
const defaultType = selectedFolder.value?.standardFiletype
if (defaultType) filingType.value = defaultType
}, { flush: "sync" })
watch([assignmentKind, assignmentSearch], ([kind, search], [previousKind]) => {
// SelectMenu clears its search after choosing an item; keep that selection available for adding.
if (kind === previousKind && !search && assignmentId.value) return
assignmentVersion++
assignmentController?.abort()
assignmentId.value = null
assignmentOptions.value = []
clearTimeout(assignmentTimer)
if (previewOpen.value) assignmentTimer = setTimeout(loadAssignmentOptions, 250)
})
watch(previewOpen, open => {
if (!open) {
suggestionVersion++
clearTimeout(suggestionTimer)
suggestionController?.abort()
suggestion.value = null
suggestionLoading.value = false
suggestionError.value = ""
suggestionMessage.value = ""
suggestionStatus.value = ""
assignmentVersion++
assignmentController?.abort()
clearTimeout(assignmentTimer)
assignments.value = []
assignmentChanges.value = {}
assignmentKinds.value = []
assignmentOptions.value = []
assignmentLoading.value = false
assignmentError.value = ""
previewDocument.value = null
filingVersion++
filingDocument.value = null
@@ -328,7 +435,10 @@ watch(() => auth.activeTenant, () => {
filingVersion++
suggestionVersion++
suggestionController?.abort()
suggestionCache.clear()
clearTimeout(suggestionTimer)
assignmentVersion++
assignmentController?.abort()
clearTimeout(assignmentTimer)
previewOpen.value = false
previewDocument.value = null
clearTimeout(searchTimer)
@@ -350,7 +460,10 @@ onBeforeUnmount(() => {
filingVersion++
suggestionVersion++
suggestionController?.abort()
suggestionCache.clear()
clearTimeout(suggestionTimer)
assignmentVersion++
assignmentController?.abort()
clearTimeout(assignmentTimer)
clearTimeout(searchTimer)
clearTimeout(pollTimer)
})
@@ -388,7 +501,10 @@ onBeforeUnmount(() => {
</thead>
<tbody class="divide-y divide-default">
<tr v-for="entry in documents" :key="entry.id" class="hover:bg-elevated/30">
<td class="max-w-sm px-4 py-4"><button class="flex items-center gap-3 text-left font-medium text-highlighted hover:text-primary" :aria-label="`${entry.name} als Vorschau anzeigen`" @click="openPreview(entry)"><UIcon name="i-heroicons-document-text" class="h-5 w-5 shrink-0 text-muted" /><span class="break-all">{{ entry.name }}</span></button></td>
<td class="max-w-sm px-4 py-4">
<button class="flex items-center gap-3 text-left font-medium text-highlighted hover:text-primary" :aria-label="`${entry.name} als Vorschau anzeigen`" @click="openPreview(entry)"><UIcon name="i-heroicons-document-text" class="h-5 w-5 shrink-0 text-muted" /><span class="break-all">{{ entry.name }}</span></button>
<UBadge v-if="entry.suggestionStatus" class="mt-2" size="xs" variant="subtle" :color="entry.suggestionStatus === 'failed' ? 'warning' : 'neutral'">{{ entry.suggestionStatus === 'ready' ? 'KI-Prüfung abgeschlossen' : entry.suggestionStatus === 'failed' ? 'KI-Prüfung fehlgeschlagen' : 'KI wird vorbereitet' }}</UBadge>
</td>
<td class="whitespace-nowrap px-4 py-4 text-muted">{{ dayjs(entry.receivedAt).format('DD.MM.YYYY HH:mm') }}</td>
<td class="whitespace-nowrap px-4 py-4 text-muted">{{ formatSize(entry.size) }}</td>
<td class="px-4 py-4"><div class="flex justify-end gap-1"><UButton icon="i-heroicons-folder-arrow-down" variant="soft" :aria-label="`${entry.name} ablegen`" @click="openFiling(entry)">Ablegen</UButton><UButton icon="i-heroicons-eye" color="neutral" variant="ghost" :aria-label="`${entry.name} als Vorschau anzeigen`" @click="openPreview(entry)">Vorschau</UButton><UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="ghost" :aria-label="`${entry.name} herunterladen`" @click="fileAction(entry, true)" /></div></td>
@@ -425,9 +541,9 @@ onBeforeUnmount(() => {
<UButton size="xs" color="neutral" variant="ghost" :disabled="filingLoading || filingSaving || suggestionLoading" @click="loadFilingSuggestion(true)">Erneut prüfen</UButton>
</div>
<div aria-live="polite" class="space-y-3">
<div v-if="suggestionLoading" class="space-y-2 text-sm text-muted">
<div v-if="suggestionLoading || (['pending', 'processing'].includes(suggestionStatus) && !suggestionError)" class="space-y-2 text-sm text-muted">
<UProgress />
<p>Der Dokumentinhalt wird geprüft …</p>
<p>{{ suggestionMessage || 'Vorbereiteten KI-Vorschlag laden …' }}</p>
</div>
<UAlert v-if="suggestionError" :title="suggestionError" color="warning" variant="subtle" />
<div v-else-if="suggestion" class="space-y-2 rounded-lg bg-elevated/50 p-3 text-sm">
@@ -439,7 +555,7 @@ onBeforeUnmount(() => {
<p class="text-muted">{{ suggestion.reason }}</p>
<UButton v-if="!suggestionSelected" size="sm" variant="soft" :disabled="filingSaving" @click="applySuggestion">KI-Vorschlag einsetzen</UButton>
</div>
<p v-else-if="suggestionMessage" class="text-sm text-muted">{{ suggestionMessage }}</p>
<p v-else-if="suggestionMessage && !['pending', 'processing'].includes(suggestionStatus)" class="text-sm text-muted">{{ suggestionMessage }}</p>
</div>
<UAlert v-if="filingError" :title="filingError" color="error" variant="subtle" />
<USkeleton v-if="filingLoading" class="h-32 w-full" />
@@ -452,8 +568,29 @@ onBeforeUnmount(() => {
</UFormField>
<UAlert v-if="!filingTypes.length && !filingError" title="Noch keine Dokumenttypen vorhanden" description="Lege zuerst unter Stammdaten → Dateitypen einen Typ an." color="warning" variant="subtle" />
<UAlert v-else-if="fixedType && !filingTypes.some(type => type.id === filingType)" title="Der vorgegebene Dokumenttyp ist nicht verfügbar." description="Bitte die Ordnereinstellungen korrigieren oder einen anderen Zielordner wählen." color="warning" variant="subtle" />
<USeparator label="Zuordnungen" />
<div v-if="assignments.length" class="space-y-2">
<div v-for="assignment in assignments" :key="assignment.kind" class="flex items-center justify-between gap-2 rounded-lg border border-default p-2 text-sm">
<div class="min-w-0">
<p class="text-xs text-muted">{{ assignmentKinds.find(kind => kind.value === assignment.kind)?.label }}</p>
<p class="break-words" :class="{ 'text-warning': assignment.available === false }">{{ assignment.name }}</p>
</div>
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" :aria-label="`${assignment.name} entfernen`" :disabled="filingSaving" @click="removeAssignment(assignment.kind)" />
</div>
</div>
<UFormField label="Bereich zuordnen">
<USelectMenu v-model="assignmentKind" :items="assignmentKinds" value-key="value" label-key="label" :disabled="filingSaving" class="w-full" />
</UFormField>
<UFormField label="Eintrag auswählen">
<USelectMenu v-model="assignmentId" v-model:search-term="assignmentSearch" :items="assignmentOptions" value-key="id" label-key="name" placeholder="Eintrag suchen …" :search-input="{ placeholder: 'Name suchen …' }" ignore-filter :loading="assignmentLoading" :disabled="filingSaving" class="w-full" />
</UFormField>
<div v-if="assignmentError" class="text-sm text-error" role="status">
{{ assignmentError }}
<UButton size="xs" color="neutral" variant="ghost" @click="loadAssignmentOptions">Erneut versuchen</UButton>
</div>
<UButton icon="i-heroicons-link" variant="soft" :disabled="!assignmentId || assignmentLoading || filingSaving" @click="addAssignment">Zuordnung hinzufügen</UButton>
</template>
<p class="text-xs text-muted">Die KI berücksichtigt bis zu fünf PDF-Seiten bzw. die erste Bildseite. Erst mit „Übernehmen und ablegen“ wird das Dokument unter Dateien gespeichert und aus dem Eingang entfernt.</p>
<p class="text-xs text-muted">Die KI berücksichtigt bis zu fünf PDF-Seiten bzw. die erste Bildseite. „Übernehmen und ablegen“ speichert Ordner, Typ und Zuordnungen gemeinsam und entfernt das Dokument aus dem Eingang.</p>
</form>
</aside>
</div>