feat(documents): file scanner inbox documents with folder and type
This commit is contained in:
@@ -3,6 +3,8 @@ 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 }
|
||||
|
||||
const { $api } = useNuxtApp()
|
||||
const auth = useAuthStore()
|
||||
@@ -24,6 +26,37 @@ const password = ref("")
|
||||
const confirmReset = ref(false)
|
||||
const scannerPath = ref("/scanner-inbox/")
|
||||
const origin = ref("")
|
||||
const filingOpen = ref(false)
|
||||
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 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 isAdmin = computed(() => Boolean(auth.user?.is_admin))
|
||||
const scannerUrl = computed(() => {
|
||||
if (!origin.value) return ""
|
||||
@@ -32,6 +65,7 @@ const scannerUrl = computed(() => {
|
||||
})
|
||||
let requestVersion = 0
|
||||
let setupVersion = 0
|
||||
let filingVersion = 0
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let disposed = false
|
||||
@@ -51,6 +85,10 @@ const load = async (silent = false) => {
|
||||
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 = ""
|
||||
@@ -63,6 +101,55 @@ const load = async (silent = false) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openFiling = async (entry: InboxDocument) => {
|
||||
filingOpen.value = true
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
filingOpen.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
|
||||
@@ -144,9 +231,26 @@ watch(search, () => {
|
||||
watch(setupOpen, open => {
|
||||
if (!open) { setupVersion++; password.value = ""; confirmReset.value = false }
|
||||
})
|
||||
watch(filingFolder, () => {
|
||||
const defaultType = selectedFolder.value?.standardFiletype
|
||||
if (defaultType) filingType.value = defaultType
|
||||
})
|
||||
watch(filingOpen, open => {
|
||||
if (!open) {
|
||||
filingVersion++
|
||||
filingDocument.value = null
|
||||
filingFolders.value = []
|
||||
filingTypes.value = []
|
||||
filingError.value = ""
|
||||
filingSaving.value = false
|
||||
filingLoading.value = false
|
||||
}
|
||||
})
|
||||
watch(() => auth.activeTenant, () => {
|
||||
requestVersion++
|
||||
setupVersion++
|
||||
filingVersion++
|
||||
filingOpen.value = false
|
||||
clearTimeout(searchTimer)
|
||||
clearTimeout(pollTimer)
|
||||
documents.value = []
|
||||
@@ -163,6 +267,7 @@ onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
requestVersion++
|
||||
setupVersion++
|
||||
filingVersion++
|
||||
clearTimeout(searchTimer)
|
||||
clearTimeout(pollTimer)
|
||||
})
|
||||
@@ -179,7 +284,7 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<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>
|
||||
@@ -190,7 +295,7 @@ onBeforeUnmount(() => {
|
||||
</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>
|
||||
<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">
|
||||
@@ -203,7 +308,7 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<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-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>
|
||||
@@ -218,6 +323,34 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</UDashboardPanelContent>
|
||||
|
||||
<UModal v-model:open="filingOpen" title="In Dateien ablegen" description="Wähle den Zielordner und klassifiziere das Dokument." :dismissible="!filingSaving" :close="!filingSaving">
|
||||
<template #body>
|
||||
<form class="space-y-5" @submit.prevent="fileDocument">
|
||||
<div v-if="filingDocument" class="flex items-center justify-between gap-3 rounded-lg bg-elevated/50 p-3">
|
||||
<span class="break-all text-sm font-medium">{{ filingDocument.name }}</span>
|
||||
<UButton icon="i-heroicons-arrow-top-right-on-square" color="neutral" variant="ghost" aria-label="Dokument öffnen" @click="fileAction(filingDocument, false)" />
|
||||
</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" />
|
||||
</UFormField>
|
||||
<UFormField label="Dokumenttyp" required :help="fixedType ? 'Dieser Dokumenttyp wird vom Zielordner vorgegeben.' : 'Die Dokumenttypen entsprechen den Dateitypen in FEDEO.'">
|
||||
<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" />
|
||||
</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-sm text-muted">Nach dem Ablegen findest du das Dokument unter Dateien. Es verschwindet aus diesem Eingang.</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="ghost" :disabled="filingSaving" @click="filingOpen = false">Abbrechen</UButton>
|
||||
<UButton type="submit" icon="i-heroicons-folder-arrow-down" :loading="filingSaving" :disabled="!canFile">In Dateien ablegen</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</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">
|
||||
|
||||
@@ -90,3 +90,103 @@ test('unmounting cancels polling and ignores in-flight responses', async () => {
|
||||
assert.equal(ui.run('documents.value.length'), 0)
|
||||
assert.equal(ui.timers.size, 0)
|
||||
})
|
||||
|
||||
const filingOptions = {
|
||||
folders: [
|
||||
{ id: 'parent', name: 'Buchhaltung', parent: null, standardFiletype: null, standardFiletypeIsOptional: true },
|
||||
{ id: 'folder', name: 'Rechnungen', parent: 'parent', standardFiletype: 'invoice', standardFiletypeIsOptional: false },
|
||||
],
|
||||
types: [{ id: 'invoice', name: 'Rechnung' }, { id: 'other', name: 'Sonstiges' }],
|
||||
}
|
||||
|
||||
test('filing shows folder paths, requires a type and honours mandatory folder defaults', async t => {
|
||||
const ui = setup(async () => filingOptions)
|
||||
t.after(ui.close)
|
||||
await ui.run('openFiling({ id: "scan", name: "scan.pdf" })')
|
||||
assert.equal(ui.run('canFile.value'), false)
|
||||
assert.equal(ui.run('folderOptions.value.find(folder => folder.id === "folder").name'), 'Buchhaltung / Rechnungen')
|
||||
ui.run('filingFolder.value = "folder"')
|
||||
await settle()
|
||||
assert.equal(ui.run('filingType.value'), 'invoice')
|
||||
assert.equal(ui.run('fixedType.value'), true)
|
||||
assert.equal(ui.run('canFile.value'), true)
|
||||
ui.run('filingType.value = "other"')
|
||||
assert.equal(ui.run('canFile.value'), false)
|
||||
ui.run('filingFolder.value = "root"')
|
||||
await settle()
|
||||
assert.equal(ui.run('fixedType.value'), false)
|
||||
assert.equal(ui.run('canFile.value'), true)
|
||||
})
|
||||
|
||||
test('successful filing sends folder and type, removes the entry and returns from an empty last page', async t => {
|
||||
const pendingSave = deferred()
|
||||
const requests = []
|
||||
const ui = setup((url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url.endsWith('/filing-options')) return Promise.resolve(filingOptions)
|
||||
if (url.endsWith('/file')) return pendingSave.promise
|
||||
return Promise.resolve({ documents: [{ id: 'remaining' }], total: 50 })
|
||||
})
|
||||
t.after(ui.close)
|
||||
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 51; page.value = 2')
|
||||
await ui.run('openFiling(documents.value[0])')
|
||||
ui.run('filingType.value = "invoice"')
|
||||
const saving = ui.run('fileDocument()')
|
||||
await ui.run('fileDocument()')
|
||||
assert.equal(requests.filter(item => item.url.endsWith('/file')).length, 1)
|
||||
assert.equal(ui.run('documents.value.length'), 1, 'keeps the original until saving succeeds')
|
||||
pendingSave.resolve({ id: 'scan' })
|
||||
await saving
|
||||
const post = requests.find(item => item.url.endsWith('/file'))
|
||||
assert.equal(post.url, '/api/document-inbox/scan/file')
|
||||
assert.equal(post.options.body.folder, null)
|
||||
assert.equal(post.options.body.type, 'invoice')
|
||||
assert.equal(ui.run('filingOpen.value'), false)
|
||||
assert.equal(ui.run('page.value'), 1)
|
||||
assert.equal(requests.at(-1).options.query.offset, 0)
|
||||
assert.equal(ui.run('documents.value[0].id'), 'remaining')
|
||||
})
|
||||
|
||||
test('filing failure preserves the document and selected values for retry', async t => {
|
||||
const ui = setup(async url => {
|
||||
if (url.endsWith('/file')) throw { data: { message: 'Ordner nicht verfügbar' } }
|
||||
return filingOptions
|
||||
})
|
||||
t.after(ui.close)
|
||||
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1')
|
||||
await ui.run('openFiling(documents.value[0])')
|
||||
ui.run('filingFolder.value = "folder"')
|
||||
await settle()
|
||||
await ui.run('fileDocument()')
|
||||
assert.equal(ui.run('filingError.value'), 'Ordner nicht verfügbar')
|
||||
assert.equal(ui.run('filingOpen.value'), true)
|
||||
assert.equal(ui.run('filingType.value'), 'invoice')
|
||||
assert.equal(ui.run('documents.value[0].id'), 'scan')
|
||||
assert.equal(ui.run('canFile.value'), true)
|
||||
})
|
||||
|
||||
test('tenant switching discards pending folder options and filing responses', async t => {
|
||||
for (const stage of ['options', 'save']) {
|
||||
const pending = deferred()
|
||||
const ui = setup(url => {
|
||||
if (url.endsWith('/filing-options')) return stage === 'options' ? pending.promise : Promise.resolve(filingOptions)
|
||||
if (url.endsWith('/file')) return pending.promise
|
||||
return Promise.resolve({ documents: [{ id: 'tenant-2' }], total: 1 })
|
||||
})
|
||||
t.after(ui.close)
|
||||
let operation = ui.run('openFiling({ id: "scan", name: "scan.pdf" })')
|
||||
if (stage === 'save') {
|
||||
await operation
|
||||
ui.run('filingType.value = "invoice"')
|
||||
operation = ui.run('fileDocument()')
|
||||
}
|
||||
ui.auth.activeTenant = '2'
|
||||
await settle()
|
||||
pending.resolve(stage === 'options' ? filingOptions : { id: 'scan' })
|
||||
await operation
|
||||
assert.equal(ui.run('filingOpen.value'), false)
|
||||
assert.equal(ui.run('filingFolders.value.length'), 0)
|
||||
assert.equal(ui.run('filingTypes.value.length'), 0)
|
||||
assert.equal(ui.run('documents.value[0].id'), 'tenant-2')
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user