634 lines
34 KiB
Vue
634 lines
34 KiB
Vue
<script setup lang="ts">
|
|
import dayjs from "dayjs"
|
|
|
|
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 = { 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)
|
|
const pageSize = 50
|
|
const search = ref("")
|
|
const loading = ref(false)
|
|
const error = ref("")
|
|
const setupOpen = ref(false)
|
|
const setupLoading = ref(false)
|
|
const setupError = ref("")
|
|
const connection = ref<ScannerConnection | null>(null)
|
|
const password = ref("")
|
|
const confirmReset = ref(false)
|
|
const scannerPath = ref("/scanner-inbox/")
|
|
const origin = ref("")
|
|
const previewOpen = ref(false)
|
|
const previewDocument = ref<InboxDocument | null>(null)
|
|
const filingDocument = ref<InboxDocument | null>(null)
|
|
const filingLoading = ref(false)
|
|
const filingSaving = ref(false)
|
|
const filingError = ref("")
|
|
const filingFolders = ref<FilingFolder[]>([])
|
|
const filingTypes = ref<FilingType[]>([])
|
|
const filingFolder = ref("root")
|
|
const filingType = ref("")
|
|
const suggestion = ref<FilingSuggestion | null>(null)
|
|
const suggestionLoading = ref(false)
|
|
const suggestionError = ref("")
|
|
const suggestionMessage = ref("")
|
|
const selectionTouched = ref(false)
|
|
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(() => {
|
|
const byId = new Map(filingFolders.value.map(folder => [folder.id, folder]))
|
|
const path = (folder: FilingFolder) => {
|
|
const parts = [folder.name]
|
|
const visited = new Set([folder.id])
|
|
let parent = folder.parent ? byId.get(folder.parent) : undefined
|
|
while (parent && !visited.has(parent.id)) {
|
|
parts.unshift(parent.name)
|
|
visited.add(parent.id)
|
|
parent = parent.parent ? byId.get(parent.parent) : undefined
|
|
}
|
|
return parts.join(" / ")
|
|
}
|
|
return [{ id: "root", name: "Dateien (Hauptebene)" }, ...filingFolders.value
|
|
.map(folder => ({ id: folder.id, name: path(folder) })).sort((a, b) => a.name.localeCompare(b.name, "de"))]
|
|
})
|
|
const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !filingSaving.value
|
|
&& (filingFolder.value === "root" || selectedFolder.value)
|
|
&& filingTypes.value.some(type => type.id === filingType.value)
|
|
&& (!fixedType.value || filingType.value === selectedFolder.value?.standardFiletype)))
|
|
const suggestionSelected = computed(() => Boolean(suggestion.value
|
|
&& (suggestion.value.folderId || "root") === filingFolder.value && suggestion.value.typeId === filingType.value))
|
|
const suggestionFolderName = computed(() => folderOptions.value.find(folder => folder.id === (suggestion.value?.folderId || "root"))?.name)
|
|
const suggestionTypeName = computed(() => filingTypes.value.find(type => type.id === suggestion.value?.typeId)?.name)
|
|
const isAdmin = computed(() => Boolean(auth.user?.is_admin))
|
|
const scannerUrl = computed(() => {
|
|
if (!origin.value) return ""
|
|
const base = String(config.public.apiBase || "").replace(/\/+$/, "")
|
|
return new URL(`${base}${scannerPath.value}`, origin.value).href
|
|
})
|
|
let requestVersion = 0
|
|
let setupVersion = 0
|
|
let filingVersion = 0
|
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
let disposed = false
|
|
|
|
const load = async (silent = false) => {
|
|
const version = ++requestVersion
|
|
clearTimeout(pollTimer)
|
|
if (!auth.activeTenant) {
|
|
documents.value = []
|
|
total.value = 0
|
|
loading.value = false
|
|
return
|
|
}
|
|
if (!silent) loading.value = true
|
|
try {
|
|
const result = await $api<{ documents: InboxDocument[]; total: number }>("/api/document-inbox", {
|
|
query: { limit: pageSize, offset: (page.value - 1) * pageSize, search: search.value.trim() },
|
|
})
|
|
if (disposed || version !== requestVersion) return
|
|
if (page.value > 1 && (page.value - 1) * pageSize >= result.total) {
|
|
page.value = Math.max(1, Math.ceil(result.total / pageSize))
|
|
return load()
|
|
}
|
|
documents.value = result.documents
|
|
total.value = result.total
|
|
error.value = ""
|
|
pollTimer = setTimeout(() => load(true), 10000)
|
|
} catch (cause: any) {
|
|
if (disposed || version !== requestVersion) return
|
|
error.value = cause?.data?.error || "Die Dokumentenablage konnte nicht geladen werden."
|
|
} finally {
|
|
if (version === requestVersion) loading.value = false
|
|
}
|
|
}
|
|
|
|
const applySuggestion = () => {
|
|
if (!suggestion.value || filingSaving.value) return
|
|
filingFolder.value = suggestion.value.folderId || "root"
|
|
filingType.value = suggestion.value.typeId
|
|
selectionTouched.value = false
|
|
}
|
|
|
|
const loadFilingSuggestion = async (refresh = false) => {
|
|
const entry = filingDocument.value
|
|
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()
|
|
suggestionLoading.value = true
|
|
suggestionError.value = ""
|
|
suggestionMessage.value = ""
|
|
try {
|
|
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."
|
|
// A delayed AI response must not replace choices the user has already made.
|
|
if (!selectionTouched.value) applySuggestion()
|
|
} catch (cause: any) {
|
|
if (disposed || version !== suggestionVersion || filingRequest !== filingVersion) return
|
|
suggestionError.value = cause?.data?.message || cause?.data?.error || "Der KI-Vorschlag konnte nicht geladen werden. Du kannst die Zuordnung manuell vornehmen."
|
|
} finally {
|
|
if (version === suggestionVersion) suggestionLoading.value = 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
|
|
filingDocument.value = entry
|
|
filingFolder.value = "root"
|
|
filingType.value = ""
|
|
filingFolders.value = []
|
|
filingTypes.value = []
|
|
filingError.value = ""
|
|
filingLoading.value = true
|
|
filingSaving.value = false
|
|
const version = ++filingVersion
|
|
try {
|
|
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 {
|
|
if (version === filingVersion) {
|
|
filingLoading.value = false
|
|
if (!filingError.value && !disposed) void loadFilingSuggestion()
|
|
}
|
|
}
|
|
}
|
|
|
|
const fileDocument = async () => {
|
|
if (!canFile.value) return
|
|
const entry = filingDocument.value!
|
|
const version = filingVersion
|
|
const folder = filingFolder.value === "root" ? null : filingFolder.value
|
|
filingSaving.value = true
|
|
filingError.value = ""
|
|
try {
|
|
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++
|
|
clearTimeout(pollTimer)
|
|
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)))
|
|
void inboxCount.refresh()
|
|
previewOpen.value = false
|
|
toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" })
|
|
await load(true)
|
|
} catch (cause: any) {
|
|
if (version === filingVersion) filingError.value = cause?.data?.error || cause?.data?.message || "Das Dokument konnte nicht abgelegt werden."
|
|
} finally {
|
|
if (version === filingVersion) filingSaving.value = false
|
|
}
|
|
}
|
|
|
|
const openSetup = async () => {
|
|
setupOpen.value = true
|
|
setupLoading.value = true
|
|
setupError.value = ""
|
|
connection.value = null
|
|
const version = ++setupVersion
|
|
try {
|
|
const result = await $api<{ connection: ScannerConnection | null; path: string }>("/api/document-inbox/scanner")
|
|
if (disposed || version !== setupVersion) return
|
|
connection.value = result.connection
|
|
scannerPath.value = result.path
|
|
} catch (cause: any) {
|
|
if (version === setupVersion) setupError.value = cause?.data?.error || "Der Scanner-Zugang konnte nicht geladen werden."
|
|
} finally {
|
|
if (version === setupVersion) setupLoading.value = false
|
|
}
|
|
}
|
|
|
|
const createCredentials = async () => {
|
|
setupLoading.value = true
|
|
setupError.value = ""
|
|
const version = ++setupVersion
|
|
try {
|
|
const result = await $api<{ connection: ScannerConnection; path: string }>("/api/document-inbox/scanner/credentials", { method: "POST" })
|
|
if (disposed || version !== setupVersion) return
|
|
connection.value = { username: result.connection.username, active: result.connection.active }
|
|
password.value = result.connection.password || ""
|
|
scannerPath.value = result.path
|
|
confirmReset.value = false
|
|
} catch (cause: any) {
|
|
if (version === setupVersion) setupError.value = cause?.data?.error || "Zugangsdaten konnten nicht erstellt werden."
|
|
} finally {
|
|
if (version === setupVersion) setupLoading.value = false
|
|
}
|
|
}
|
|
|
|
const disableScanner = async () => {
|
|
setupLoading.value = true
|
|
const version = ++setupVersion
|
|
try {
|
|
await $api("/api/document-inbox/scanner", { method: "DELETE" })
|
|
if (disposed || version !== setupVersion) return
|
|
if (connection.value) connection.value.active = false
|
|
password.value = ""
|
|
confirmReset.value = false
|
|
} catch (cause: any) {
|
|
if (version === setupVersion) setupError.value = cause?.data?.error || "Der Scanner-Zugang konnte nicht deaktiviert werden."
|
|
} finally {
|
|
if (version === setupVersion) setupLoading.value = false
|
|
}
|
|
}
|
|
|
|
const copy = async (value: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(value)
|
|
toast.add({ title: "Kopiert", color: "success" })
|
|
} catch {
|
|
toast.add({ title: "Bitte den Wert markieren und manuell kopieren.", color: "warning" })
|
|
}
|
|
}
|
|
const fileAction = async (entry: InboxDocument, download: boolean) => {
|
|
try {
|
|
if (download) await fileActions.downloadFile(entry.id)
|
|
else await fileActions.openFile(entry.id)
|
|
} catch {
|
|
toast.add({ title: "Das Dokument konnte nicht geöffnet werden.", color: "error" })
|
|
}
|
|
}
|
|
const formatSize = (size: number) => size < 1024 * 1024 ? `${Math.ceil(size / 1024)} KB` : `${(size / 1024 / 1024).toLocaleString("de-DE", { maximumFractionDigits: 1 })} MB`
|
|
const changePage = (next: number) => { page.value = next; load() }
|
|
|
|
watch(search, () => {
|
|
// Prevent an older response from briefly replacing results for a new search.
|
|
requestVersion++
|
|
clearTimeout(searchTimer)
|
|
clearTimeout(pollTimer)
|
|
searchTimer = setTimeout(() => { page.value = 1; load() }, 300)
|
|
})
|
|
watch(setupOpen, open => {
|
|
if (!open) { setupVersion++; password.value = ""; confirmReset.value = false }
|
|
})
|
|
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
|
|
filingFolders.value = []
|
|
filingTypes.value = []
|
|
filingError.value = ""
|
|
filingSaving.value = false
|
|
filingLoading.value = false
|
|
}
|
|
})
|
|
watch(() => auth.activeTenant, () => {
|
|
requestVersion++
|
|
setupVersion++
|
|
filingVersion++
|
|
suggestionVersion++
|
|
suggestionController?.abort()
|
|
clearTimeout(suggestionTimer)
|
|
assignmentVersion++
|
|
assignmentController?.abort()
|
|
clearTimeout(assignmentTimer)
|
|
previewOpen.value = false
|
|
previewDocument.value = null
|
|
clearTimeout(searchTimer)
|
|
clearTimeout(pollTimer)
|
|
documents.value = []
|
|
total.value = 0
|
|
page.value = 1
|
|
error.value = ""
|
|
setupOpen.value = false
|
|
connection.value = null
|
|
password.value = ""
|
|
load()
|
|
})
|
|
onMounted(() => { origin.value = window.location.origin; load() })
|
|
onBeforeUnmount(() => {
|
|
disposed = true
|
|
requestVersion++
|
|
setupVersion++
|
|
filingVersion++
|
|
suggestionVersion++
|
|
suggestionController?.abort()
|
|
clearTimeout(suggestionTimer)
|
|
assignmentVersion++
|
|
assignmentController?.abort()
|
|
clearTimeout(assignmentTimer)
|
|
clearTimeout(searchTimer)
|
|
clearTimeout(pollTimer)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardNavbar title="Dokumentenablage">
|
|
<template #right>
|
|
<UButton v-if="isAdmin && auth.activeTenant" icon="i-heroicons-cog-6-tooth" color="neutral" variant="outline" @click="openSetup">Scanner einrichten</UButton>
|
|
<UButton icon="i-heroicons-arrow-path" color="neutral" variant="ghost" aria-label="Dokumentenablage aktualisieren" :loading="loading" @click="load()" />
|
|
</template>
|
|
</UDashboardNavbar>
|
|
<UDashboardPanelContent class="space-y-6">
|
|
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-xl font-semibold text-highlighted">Eingegangene Dokumente</h1>
|
|
<p class="mt-1 text-sm text-muted">Prüfe eingegangene Scans und lege sie mit Ordner und Dokumenttyp unter Dateien ab.</p>
|
|
</div>
|
|
<UInput v-model="search" aria-label="Dokumente nach Dateiname suchen" placeholder="Dateiname suchen …" icon="i-heroicons-magnifying-glass" :maxlength="200" class="w-full sm:w-72" />
|
|
</div>
|
|
<UAlert v-if="!auth.activeTenant" title="Bitte einen Mandanten auswählen." color="neutral" variant="subtle" />
|
|
<UAlert v-else-if="error" :title="error" color="error" variant="subtle" />
|
|
<div v-if="loading && !documents.length" class="space-y-3" aria-label="Dokumente werden geladen">
|
|
<USkeleton v-for="index in 4" :key="index" class="h-16 w-full" />
|
|
</div>
|
|
<div v-else-if="!documents.length && !error" class="rounded-xl border border-dashed border-default px-6 py-16 text-center">
|
|
<UIcon name="i-heroicons-inbox-arrow-down" class="mx-auto h-10 w-10 text-muted" />
|
|
<h2 class="mt-3 font-semibold">{{ search ? 'Keine passenden Dokumente' : 'Keine offenen Dokumente' }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ search ? 'Versuche einen anderen Dateinamen.' : 'Sobald dein Scanner eine Datei überträgt, erscheint sie hier automatisch.' }}</p>
|
|
</div>
|
|
<div v-else-if="documents.length" class="overflow-x-auto rounded-xl border border-default">
|
|
<table class="w-full text-left text-sm">
|
|
<thead class="border-b border-default bg-elevated/50 text-muted">
|
|
<tr><th scope="col" class="px-4 py-3">Dokument</th><th scope="col" class="px-4 py-3">Eingegangen</th><th scope="col" class="px-4 py-3">Größe</th><th scope="col" class="px-4 py-3 text-right">Aktionen</th></tr>
|
|
</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>
|
|
<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>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div v-if="total > 0" class="flex items-center justify-between gap-3 text-sm text-muted">
|
|
<span>{{ total }} {{ total === 1 ? 'Dokument' : 'Dokumente' }}</span>
|
|
<div v-if="total > pageSize" class="flex items-center gap-3">
|
|
<UButton color="neutral" variant="outline" :disabled="page <= 1 || loading" @click="changePage(page - 1)">Zurück</UButton>
|
|
<span>Seite {{ page }} von {{ Math.ceil(total / pageSize) }}</span>
|
|
<UButton color="neutral" variant="outline" :disabled="page * pageSize >= total || loading" @click="changePage(page + 1)">Weiter</UButton>
|
|
</div>
|
|
</div>
|
|
</UDashboardPanelContent>
|
|
|
|
<UModal v-model:open="previewOpen" :title="previewDocument?.name || 'Dokumentvorschau'" description="Dokument prüfen und mit Ordner und Typ ablegen." :dismissible="!filingSaving" :close="!filingSaving" :ui="{ content: 'w-[calc(100vw-2rem)] max-w-7xl h-[90dvh]', body: 'min-h-0 flex-1 overflow-y-auto lg:overflow-hidden p-0 sm:p-0', title: 'break-all pr-8', footer: 'flex-wrap justify-end' }">
|
|
<template #body>
|
|
<div class="lg:flex lg:h-full lg:min-h-0">
|
|
<div class="h-[50dvh] min-w-0 lg:h-full lg:flex-1">
|
|
<AuthenticatedFilePreview
|
|
v-if="previewOpen && previewDocument"
|
|
:key="previewDocument.id"
|
|
:file-id="previewDocument.id"
|
|
:path="previewDocument.name"
|
|
interactive
|
|
/>
|
|
</div>
|
|
<aside class="border-t border-default p-5 lg:w-88 lg:shrink-0 lg:overflow-y-auto lg:border-t-0 lg:border-l">
|
|
<form id="inbox-preview-filing" class="space-y-5" @submit.prevent="fileDocument">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<h2 class="flex items-center gap-2 font-semibold"><UIcon name="i-heroicons-sparkles" /> KI-Zuordnung</h2>
|
|
<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 || (['pending', 'processing'].includes(suggestionStatus) && !suggestionError)" class="space-y-2 text-sm text-muted">
|
|
<UProgress />
|
|
<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">
|
|
<UBadge :color="suggestion.confidence === 'high' ? 'success' : 'warning'" variant="subtle">{{ suggestion.confidence === 'high' ? 'Passender Vorschlag' : 'Bitte Zuordnung prüfen' }}</UBadge>
|
|
<dl class="space-y-1">
|
|
<div><dt class="inline text-muted">Ordner: </dt><dd class="inline break-words">{{ suggestionFolderName }}</dd></div>
|
|
<div><dt class="inline text-muted">Typ: </dt><dd class="inline">{{ suggestionTypeName }}</dd></div>
|
|
</dl>
|
|
<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 && !['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" />
|
|
<template v-else>
|
|
<UFormField label="Zielordner" required>
|
|
<USelectMenu v-model="filingFolder" :items="folderOptions" value-key="id" label-key="name" :search-input="{ placeholder: 'Ordner suchen …' }" :disabled="filingSaving" class="w-full" @update:model-value="selectionTouched = true" />
|
|
</UFormField>
|
|
<UFormField label="Dokumenttyp" required :help="fixedType ? 'Dieser Dokumenttyp wird vom Zielordner vorgegeben.' : undefined">
|
|
<USelectMenu v-model="filingType" :items="filingTypes" value-key="id" label-key="name" placeholder="Dokumenttyp wählen …" :search-input="{ placeholder: 'Dokumenttyp suchen …' }" :disabled="filingSaving || fixedType" class="w-full" @update:model-value="selectionTouched = true" />
|
|
</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. „Übernehmen und ablegen“ speichert Ordner, Typ und Zuordnungen gemeinsam und entfernt das Dokument aus dem Eingang.</p>
|
|
</form>
|
|
</aside>
|
|
</div>
|
|
</template>
|
|
<template #footer>
|
|
<template v-if="previewDocument">
|
|
<UButton icon="i-heroicons-arrow-top-right-on-square" color="neutral" variant="ghost" @click="fileAction(previewDocument, false)">In neuem Tab öffnen</UButton>
|
|
<UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="outline" @click="fileAction(previewDocument, true)">Herunterladen</UButton>
|
|
<UButton type="submit" form="inbox-preview-filing" icon="i-heroicons-folder-arrow-down" :loading="filingSaving" :disabled="!canFile">Übernehmen und ablegen</UButton>
|
|
</template>
|
|
</template>
|
|
</UModal>
|
|
|
|
<UModal v-model:open="setupOpen" title="Scanner einrichten" description="Verbinde deinen Epson-Scanner mit der Dokumentenablage dieses Mandanten.">
|
|
<template #body>
|
|
<div class="space-y-5">
|
|
<UAlert v-if="setupError" :title="setupError" color="error" variant="subtle" />
|
|
<p class="text-sm text-muted">Am Scanner „Netzwerkordner/FTP“ und als Kommunikationsmodus „WebDAV (HTTPS)“ wählen. In Web Config heißt das Ziel gegebenenfalls „SharePoint (WebDAV)“.</p>
|
|
<UFormField label="Zieladresse">
|
|
<div class="flex gap-2"><UInput :model-value="scannerUrl" readonly class="flex-1" /><UButton icon="i-heroicons-clipboard" color="neutral" variant="outline" aria-label="Zieladresse kopieren" @click="copy(scannerUrl)" /></div>
|
|
</UFormField>
|
|
<template v-if="connection">
|
|
<UBadge :color="connection.active ? 'success' : 'neutral'" variant="subtle">{{ connection.active ? 'Scanner-Zugang aktiv' : 'Scanner-Zugang deaktiviert' }}</UBadge>
|
|
<UFormField label="Benutzername"><div class="flex gap-2"><UInput :model-value="connection.username" readonly class="flex-1" /><UButton icon="i-heroicons-clipboard" color="neutral" variant="outline" aria-label="Benutzername kopieren" @click="copy(connection.username)" /></div></UFormField>
|
|
<UFormField v-if="password" label="Passwort" help="Wird nur jetzt angezeigt. Bitte vor dem Schließen im Scanner hinterlegen."><div class="flex gap-2"><UInput :model-value="password" readonly class="flex-1 font-mono" autocomplete="off" /><UButton icon="i-heroicons-clipboard" color="neutral" variant="outline" aria-label="Passwort kopieren" @click="copy(password)" /></div></UFormField>
|
|
</template>
|
|
<p class="text-sm text-muted">PDF und Duplex einstellen und das Ziel als „FEDEO“ speichern. Jede Datei bleibt ein Dokument, auch bei mehreren Seiten. Dateinamen mit Datum, Uhrzeit oder laufender Nummer verwenden. Maximal 100 MB pro Datei.</p>
|
|
<div v-if="confirmReset" class="space-y-3 rounded-lg border border-warning/30 bg-warning/10 p-4">
|
|
<p class="text-sm">Das bisherige Passwort wird ungültig. Alle Scanner mit diesem Zugang benötigen anschließend das neue Passwort.</p>
|
|
<div class="flex gap-2"><UButton :loading="setupLoading" @click="createCredentials">Neues Passwort erstellen</UButton><UButton color="neutral" variant="ghost" :disabled="setupLoading" @click="confirmReset = false">Abbrechen</UButton></div>
|
|
</div>
|
|
<div v-else class="flex flex-wrap gap-2">
|
|
<UButton v-if="!connection" :loading="setupLoading" :disabled="Boolean(setupError)" @click="createCredentials">Scanner-Zugang erstellen</UButton>
|
|
<UButton v-else color="neutral" variant="outline" :disabled="setupLoading" @click="confirmReset = true">{{ connection.active ? 'Neues Passwort erstellen' : 'Mit neuem Passwort aktivieren' }}</UButton>
|
|
<UButton v-if="connection?.active" color="error" variant="ghost" :loading="setupLoading" @click="disableScanner">Zugang deaktivieren</UButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</template>
|