Files
FEDEO/frontend/pages/organisation/dokumentenablage.vue
flfeders 23d49f7186
All checks were successful
Build and Release FEDEO / verify (push) Successful in 56s
Build and Release FEDEO / build-and-release (push) Successful in 5m54s
feat(documents): suggest inbox folder and type with AI
2026-10-01 18:11:41 +02:00

497 lines
26 KiB
Vue

<script setup lang="ts">
import dayjs from "dayjs"
type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: 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 }
const { $api } = useNuxtApp()
const auth = useAuthStore()
const config = useRuntimeConfig()
const fileActions = useFiles()
const toast = useToast()
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 suggestionCache = new Map<string, SuggestionResult>()
let suggestionController: AbortController | undefined
let suggestionVersion = 0
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 || filingLoading.value || filingSaving.value || suggestionLoading.value) return
const version = ++suggestionVersion
const filingRequest = filingVersion
suggestionController?.abort()
suggestionController = new AbortController()
suggestionLoading.value = true
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,
})
if (disposed || version !== suggestionVersion || filingRequest !== filingVersion) 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) {
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 openPreview = (entry: InboxDocument) => openFiling(entry)
const openFiling = async (entry: InboxDocument) => {
suggestionVersion++
suggestionController?.abort()
suggestion.value = null
suggestionLoading.value = false
suggestionError.value = ""
suggestionMessage.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 = await $api<{ folders: FilingFolder[]; types: FilingType[] }>("/api/document-inbox/filing-options")
if (disposed || version !== filingVersion) return
filingFolders.value = result.folders
filingTypes.value = result.types
} 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 } })
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)))
suggestionCache.delete(entry.id)
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(previewOpen, open => {
if (!open) {
suggestionVersion++
suggestionController?.abort()
suggestion.value = null
suggestionLoading.value = false
suggestionError.value = ""
suggestionMessage.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()
suggestionCache.clear()
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()
suggestionCache.clear()
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></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" class="space-y-2 text-sm text-muted">
<UProgress />
<p>Der Dokumentinhalt wird geprüft …</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" 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" />
</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>
</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>