Files
FEDEO/frontend/pages/organisation/dokumentenablage.vue

248 lines
13 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 }
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 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 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
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 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(() => auth.activeTenant, () => {
requestVersion++
setupVersion++
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++
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">Hier landen die Dokumente deines Scanners. Jede übertragene Datei erscheint einzeln.</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' : 'Noch keine Dokumente eingegangen' }}</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" @click="fileAction(entry, false)"><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-arrow-top-right-on-square" color="neutral" variant="ghost" :aria-label="`${entry.name} öffnen`" @click="fileAction(entry, false)" /><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="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>