feat(documents): prepare inbox suggestions and add entity assignments
This commit is contained in:
@@ -8,6 +8,7 @@ const props = defineProps({
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const { count: inboxCount } = useDocumentInboxCount()
|
||||
const { has } = usePermission()
|
||||
const tenantExtraModules = computed(() => {
|
||||
const modules = auth.activeTenantData?.extraModules
|
||||
@@ -55,7 +56,8 @@ const links = computed(() => {
|
||||
featureEnabled("files") ? {
|
||||
label: "Dokumentenablage",
|
||||
to: "/organisation/dokumentenablage",
|
||||
icon: "i-heroicons-inbox-arrow-down"
|
||||
icon: "i-heroicons-inbox-arrow-down",
|
||||
badge: inboxCount.value > 0 ? String(inboxCount.value) : undefined,
|
||||
} : null,
|
||||
featureEnabled("files") ? {
|
||||
label: "Dateien",
|
||||
|
||||
31
frontend/composables/useDocumentInboxCount.ts
Normal file
31
frontend/composables/useDocumentInboxCount.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createSharedComposable } from "@vueuse/core"
|
||||
|
||||
export const useDocumentInboxCount = createSharedComposable(() => {
|
||||
const auth = useAuthStore()
|
||||
const { $api } = useNuxtApp()
|
||||
const count = ref(0)
|
||||
const scope = computed(() => auth.user?.id && auth.activeTenant && !auth.profile?.customer_for_portal && auth.activeTenantData?.features?.files !== false
|
||||
? `${auth.user.id}:${auth.activeTenant}` : "")
|
||||
let version = 0
|
||||
let disposed = false
|
||||
let controller: AbortController | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const refresh = async () => {
|
||||
const request = ++version
|
||||
clearTimeout(timer)
|
||||
controller?.abort()
|
||||
if (!scope.value || disposed) { count.value = 0; return }
|
||||
controller = new AbortController()
|
||||
try {
|
||||
const result = await $api<{ total: number }>("/api/document-inbox/count", { signal: controller.signal })
|
||||
if (!disposed && request === version) count.value = Math.max(0, Number(result.total) || 0)
|
||||
} catch {
|
||||
if (request === version) count.value = 0
|
||||
} finally {
|
||||
if (!disposed && request === version && scope.value) timer = setTimeout(refresh, 10000)
|
||||
}
|
||||
}
|
||||
watch(scope, () => { count.value = 0; void refresh() }, { immediate: true, flush: "sync" })
|
||||
onScopeDispose(() => { disposed = true; version++; controller?.abort(); clearTimeout(timer) })
|
||||
return { count, refresh }
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
62
frontend/tests/document-inbox-count.test.cjs
Normal file
62
frontend/tests/document-inbox-count.test.cjs
Normal file
@@ -0,0 +1,62 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const vue = require('vue')
|
||||
const ts = require('../../backend/node_modules/typescript')
|
||||
const settle = () => new Promise(setImmediate)
|
||||
|
||||
function setup(api) {
|
||||
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1', profile: null, activeTenantData: {} })
|
||||
const timers = new Map()
|
||||
let nextTimer = 0
|
||||
const scope = vue.effectScope()
|
||||
const context = vm.createContext({
|
||||
exports: {}, AbortController,
|
||||
require: name => name === '@vueuse/core' ? { createSharedComposable: fn => fn } : require(name),
|
||||
ref: vue.ref, computed: vue.computed, watch: vue.watch, onScopeDispose: vue.onScopeDispose,
|
||||
useAuthStore: () => auth, useNuxtApp: () => ({ $api: api }),
|
||||
setTimeout: fn => { timers.set(++nextTimer, fn); return nextTimer }, clearTimeout: id => timers.delete(id),
|
||||
})
|
||||
const source = fs.readFileSync(`${__dirname}/../composables/useDocumentInboxCount.ts`, 'utf8')
|
||||
vm.runInContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, context)
|
||||
const counter = scope.run(() => context.exports.useDocumentInboxCount())
|
||||
return { auth, counter, timers, close: () => scope.stop() }
|
||||
}
|
||||
|
||||
test('menu count refreshes after filing and cancels its timer when navigation is unmounted', async () => {
|
||||
let count = 3
|
||||
const ui = setup(async () => ({ total: count }))
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 3)
|
||||
assert.equal(ui.timers.size, 1)
|
||||
count = 2
|
||||
await ui.counter.refresh()
|
||||
assert.equal(ui.counter.count.value, 2)
|
||||
assert.equal(ui.timers.size, 1)
|
||||
ui.close()
|
||||
assert.equal(ui.timers.size, 0)
|
||||
})
|
||||
|
||||
test('previous tenant responses cannot replace the current badge; portal and logout clear it', async t => {
|
||||
let finish
|
||||
let requests = 0
|
||||
const ui = setup(() => ++requests === 1 ? new Promise(resolve => { finish = resolve }) : Promise.resolve({ total: 2 }))
|
||||
t.after(ui.close)
|
||||
ui.auth.activeTenant = '2'
|
||||
assert.equal(ui.counter.count.value, 0)
|
||||
await settle()
|
||||
finish({ total: 999 })
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 2)
|
||||
ui.auth.profile = { customer_for_portal: 15 }
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 0)
|
||||
assert.equal(ui.timers.size, 0)
|
||||
ui.auth.profile = null
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 2)
|
||||
ui.auth.user = null
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 0)
|
||||
})
|
||||
@@ -18,13 +18,15 @@ function setup(api, apiBase = 'https://fedeo.example/backend') {
|
||||
let timerId = 0
|
||||
let mount
|
||||
let unmount
|
||||
let countRefreshes = 0
|
||||
const context = vm.createContext({
|
||||
require, exports: {}, URL, AbortController,
|
||||
ref: vue.ref, computed: vue.computed, watch: vue.watch,
|
||||
useAuthStore: () => auth,
|
||||
useDocumentInboxCount: () => ({ refresh: async () => { countRefreshes++ } }),
|
||||
useRuntimeConfig: () => ({ public: { apiBase } }),
|
||||
useFiles: () => ({}), useToast: () => ({ add() {} }),
|
||||
useNuxtApp: () => ({ $api: api }),
|
||||
useNuxtApp: () => ({ $api: (url, options) => /\/assignments$/.test(url) ? Promise.resolve([]) : api(url, options) }),
|
||||
window: { location: { origin: 'https://fedeo.example' } },
|
||||
onMounted: handler => { mount = handler },
|
||||
onBeforeUnmount: handler => { unmount = handler },
|
||||
@@ -33,7 +35,7 @@ function setup(api, apiBase = 'https://fedeo.example/backend') {
|
||||
})
|
||||
const source = fs.readFileSync(`${__dirname}/../pages/organisation/dokumentenablage.vue`, 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||
vm.runInContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, context)
|
||||
return { auth, timers, mount, close: () => unmount(), run: code => vm.runInContext(code, context) }
|
||||
return { auth, timers, mount, countRefreshes: () => countRefreshes, close: () => unmount(), run: code => vm.runInContext(code, context) }
|
||||
}
|
||||
|
||||
test('scanner setup preserves absolute and relative backend prefixes', async t => {
|
||||
@@ -193,12 +195,12 @@ test('tenant switching discards pending folder options and filing responses', as
|
||||
|
||||
const aiSuggestion = { folderId: 'folder', typeId: 'invoice', confidence: 'high', reason: 'Rechnung erkannt.' }
|
||||
|
||||
test('AI preselection only files the document after confirmation and reuses its preview result', async t => {
|
||||
test('AI preselection only files the document after confirmation and reads the prepared result without triggering AI', async t => {
|
||||
const requests = []
|
||||
const ui = setup(async (url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url.endsWith('/filing-options')) return filingOptions
|
||||
if (url.endsWith('/suggestion')) return { suggestion: aiSuggestion }
|
||||
if (url.endsWith('/suggestion')) return { status: 'ready', suggestion: aiSuggestion }
|
||||
if (url.endsWith('/file')) return { id: 'scan' }
|
||||
return { documents: [], total: 0 }
|
||||
})
|
||||
@@ -213,12 +215,14 @@ test('AI preselection only files the document after confirmation and reuses its
|
||||
await settle()
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
assert.equal(requests.filter(request => request.url.endsWith('/suggestion')).length, 1)
|
||||
assert.equal(requests.filter(request => request.url.endsWith('/suggestion')).length, 2)
|
||||
assert.ok(requests.filter(request => request.url.endsWith('/suggestion')).every(request => request.options.method === 'GET'))
|
||||
await ui.run('fileDocument()')
|
||||
const save = requests.find(request => request.url.endsWith('/file'))
|
||||
assert.equal(save.options.body.folder, 'folder')
|
||||
assert.equal(save.options.body.type, 'invoice')
|
||||
assert.equal(ui.run('previewOpen.value'), false)
|
||||
assert.equal(ui.countRefreshes(), 1)
|
||||
})
|
||||
|
||||
test('late AI results preserve manual choices, and can still be explicitly applied', async t => {
|
||||
@@ -227,7 +231,7 @@ test('late AI results preserve manual choices, and can still be explicitly appli
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
ui.run('filingType.value = "other"; selectionTouched.value = true')
|
||||
pending.resolve({ suggestion: aiSuggestion })
|
||||
pending.resolve({ status: 'ready', suggestion: aiSuggestion })
|
||||
await settle()
|
||||
assert.equal(ui.run('filingFolder.value'), 'root')
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
@@ -257,10 +261,9 @@ test('closing, changing documents or switching tenant cancels and discards pendi
|
||||
// Keep the replacement document's request separate from the stale first result.
|
||||
if (action === 'document') ui.run('previewOpen.value = false')
|
||||
await settle()
|
||||
pending.resolve({ suggestion: aiSuggestion })
|
||||
pending.resolve({ status: 'ready', suggestion: aiSuggestion })
|
||||
await settle()
|
||||
assert.equal(ui.run('suggestion.value'), null)
|
||||
assert.equal(ui.run('suggestionCache.size'), 0)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -279,3 +282,113 @@ test('AI failures leave manual filing available and optional folder defaults do
|
||||
await settle()
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
})
|
||||
|
||||
test('pending preparation is polled, preserves manual choices and only explicit refresh queues work', async t => {
|
||||
const requests = []
|
||||
let state = 'pending'
|
||||
const ui = setup(async (url, options) => {
|
||||
if (url.endsWith('/filing-options')) return filingOptions
|
||||
requests.push(options.method)
|
||||
return { status: state, suggestion: state === 'ready' ? aiSuggestion : null }
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
assert.equal(ui.run('suggestionStatus.value'), 'pending')
|
||||
assert.equal(ui.run('suggestion.value'), null)
|
||||
assert.equal(ui.timers.size, 1)
|
||||
ui.run('filingType.value = "other"; selectionTouched.value = true')
|
||||
state = 'ready'
|
||||
await ui.run('loadFilingSuggestion()')
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
assert.equal(ui.run('suggestion.value.typeId'), 'invoice')
|
||||
assert.equal(ui.timers.size, 0)
|
||||
await ui.run('loadFilingSuggestion(true)')
|
||||
assert.deepEqual(requests, ['GET', 'GET', 'POST'])
|
||||
})
|
||||
|
||||
test('preparation polling resumes when a concurrent manual filing attempt fails', async t => {
|
||||
const save = deferred()
|
||||
let state = 'processing'
|
||||
const ui = setup(async url => {
|
||||
if (url.endsWith('/filing-options')) return filingOptions
|
||||
if (url.endsWith('/suggestion')) return { status: state, suggestion: state === 'ready' ? aiSuggestion : null }
|
||||
if (url.endsWith('/file')) { await save.promise; throw { data: { error: 'Bitte erneut versuchen' } } }
|
||||
})
|
||||
t.after(ui.close)
|
||||
const tick = async () => {
|
||||
assert.equal(ui.timers.size, 1)
|
||||
const [id, callback] = [...ui.timers][0]
|
||||
ui.timers.delete(id)
|
||||
await callback()
|
||||
}
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
ui.run('filingType.value = "other"; selectionTouched.value = true')
|
||||
const filing = ui.run('fileDocument()')
|
||||
await tick()
|
||||
assert.equal(ui.timers.size, 1, 'polling remains scheduled while filing is in progress')
|
||||
save.resolve()
|
||||
await filing
|
||||
assert.equal(ui.run('previewOpen.value'), true)
|
||||
state = 'ready'
|
||||
await tick()
|
||||
assert.equal(ui.run('suggestion.value.typeId'), 'invoice')
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
assert.equal(ui.timers.size, 0)
|
||||
})
|
||||
|
||||
test('entity assignments are submitted together with filing and search reset preserves a selected item', async t => {
|
||||
const requests = []
|
||||
const ui = setup(async (url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url.endsWith('/filing-options')) return { ...filingOptions, assignmentKinds: [{ value: 'project', label: 'Projekt' }, { value: 'customer', label: 'Kunde' }] }
|
||||
if (url.endsWith('/assignment-options')) return [{ id: '11', name: 'Umbau' }]
|
||||
if (url.endsWith('/suggestion')) return { status: 'ready', suggestion: aiSuggestion }
|
||||
if (url.endsWith('/file')) return { id: 'scan' }
|
||||
return { documents: [], total: 0 }
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
ui.run('assignmentSearch.value = "Umbau"')
|
||||
await settle()
|
||||
await ui.run('loadAssignmentOptions()')
|
||||
ui.run('assignmentId.value = "11"; assignmentSearch.value = ""')
|
||||
await settle()
|
||||
assert.equal(ui.run('assignmentId.value'), '11')
|
||||
ui.run('addAssignment()')
|
||||
assert.equal(ui.run('assignments.value[0].name'), 'Umbau')
|
||||
assert.equal(requests.some(request => request.url.endsWith('/file')), false)
|
||||
ui.run('assignmentKind.value = "customer"')
|
||||
await settle()
|
||||
ui.run('assignmentOptions.value = [{ id: "21", name: "Kunde" }]; assignmentId.value = "21"; assignmentLoading.value = false; addAssignment(); removeAssignment("project")')
|
||||
await ui.run('fileDocument()')
|
||||
const saved = requests.find(request => request.url.endsWith('/file'))
|
||||
assert.equal(saved.options.body.assignments.project, null)
|
||||
assert.equal(saved.options.body.assignments.customer, '21')
|
||||
assert.equal(saved.options.body.folder, 'folder')
|
||||
assert.equal(saved.options.body.type, 'invoice')
|
||||
assert.equal(ui.countRefreshes(), 1)
|
||||
})
|
||||
|
||||
test('old entity search responses are discarded on category and tenant changes', async t => {
|
||||
const pending = deferred()
|
||||
const ui = setup(url => {
|
||||
if (url.endsWith('/filing-options')) return Promise.resolve({ ...filingOptions, assignmentKinds: [{ value: 'project', label: 'Projekt' }, { value: 'customer', label: 'Kunde' }] })
|
||||
if (url.endsWith('/assignment-options')) return pending.promise
|
||||
if (url.endsWith('/suggestion')) return Promise.resolve({ status: 'ready', suggestion: null })
|
||||
return Promise.resolve({ documents: [], total: 0 })
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
ui.run('assignmentKind.value = "customer"')
|
||||
await settle()
|
||||
ui.auth.activeTenant = '2'
|
||||
await settle()
|
||||
pending.resolve([{ id: 'foreign', name: 'Previous tenant project' }])
|
||||
await settle()
|
||||
assert.equal(ui.run('assignmentOptions.value.length'), 0)
|
||||
assert.equal(ui.run('assignments.value.length'), 0)
|
||||
assert.equal(ui.timers.size, 1, 'only the inbox list polling remains')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user