feat(documents): allow archiving inbox documents
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 5m56s

This commit is contained in:
2026-10-02 17:12:05 +02:00
parent 09a002d600
commit 1885bfc521
6 changed files with 243 additions and 13 deletions

View File

@@ -36,6 +36,12 @@ const previewDocument = ref<InboxDocument | null>(null)
const filingDocument = ref<InboxDocument | null>(null)
const filingLoading = ref(false)
const filingSaving = ref(false)
const archiveOpen = ref(false)
const archiveTarget = ref<InboxDocument | null>(null)
const archiveSaving = ref(false)
const archiveError = ref("")
let archiveVersion = 0
const documentBusy = computed(() => filingSaving.value || archiveSaving.value)
const filingError = ref("")
const filingFolders = ref<FilingFolder[]>([])
const filingTypes = ref<FilingType[]>([])
@@ -80,7 +86,7 @@ const folderOptions = computed(() => {
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
const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !documentBusy.value
&& (filingFolder.value === "root" || selectedFolder.value)
&& filingTypes.value.some(type => type.id === filingType.value)
&& (!fixedType.value || filingType.value === selectedFolder.value?.standardFiletype)))
@@ -225,6 +231,7 @@ const removeAssignment = (kind: string) => {
const openPreview = (entry: InboxDocument) => openFiling(entry)
const openFiling = async (entry: InboxDocument) => {
if (disposed || documentBusy.value) return
suggestionVersion++
clearTimeout(suggestionTimer)
suggestionController?.abort()
@@ -278,6 +285,44 @@ const openFiling = async (entry: InboxDocument) => {
}
}
const removeInboxEntry = (id: string) => {
// Discard older list responses that still contain the completed document.
requestVersion++
clearTimeout(pollTimer)
if (documents.value.some(document => document.id === id)) total.value = Math.max(0, total.value - 1)
documents.value = documents.value.filter(document => document.id !== id)
page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize)))
void inboxCount.refresh()
if (previewDocument.value?.id === id) previewOpen.value = false
}
const openArchive = (entry: InboxDocument) => {
if (disposed || documentBusy.value || !auth.activeTenant) return
archiveTarget.value = entry
archiveError.value = ""
archiveOpen.value = true
}
const archiveDocument = async () => {
const entry = archiveTarget.value
if (!entry || !archiveOpen.value || disposed || documentBusy.value || !auth.activeTenant) return
const version = ++archiveVersion
archiveSaving.value = true
archiveError.value = ""
try {
await $api(`/api/document-inbox/${entry.id}/archive`, { method: "POST" })
if (disposed || version !== archiveVersion) return
removeInboxEntry(entry.id)
archiveOpen.value = false
toast.add({ title: "Dokument archiviert", description: `${entry.name} wurde aus der Ablage entfernt.`, color: "success" })
await load(true)
} catch (cause: any) {
if (!disposed && version === archiveVersion) archiveError.value = cause?.data?.error || cause?.data?.message || "Das Dokument konnte nicht archiviert werden."
} finally {
if (version === archiveVersion) archiveSaving.value = false
}
}
const fileDocument = async () => {
if (!canFile.value) return
const entry = filingDocument.value!
@@ -288,14 +333,7 @@ const fileDocument = async () => {
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
removeInboxEntry(entry.id)
toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" })
await load(true)
} catch (cause: any) {
@@ -429,7 +467,15 @@ watch(previewOpen, open => {
filingLoading.value = false
}
})
watch(archiveOpen, open => {
if (!open) { archiveTarget.value = null; archiveError.value = "" }
})
watch(() => auth.activeTenant, () => {
archiveVersion++
archiveOpen.value = false
archiveTarget.value = null
archiveError.value = ""
archiveSaving.value = false
requestVersion++
setupVersion++
filingVersion++
@@ -455,6 +501,7 @@ watch(() => auth.activeTenant, () => {
onMounted(() => { origin.value = window.location.origin; load() })
onBeforeUnmount(() => {
disposed = true
archiveVersion++
requestVersion++
setupVersion++
filingVersion++
@@ -507,7 +554,12 @@ onBeforeUnmount(() => {
</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>
<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`" :disabled="documentBusy" @click="openFiling(entry)">Ablegen</UButton>
<UButton icon="i-heroicons-eye" color="neutral" variant="ghost" :aria-label="`${entry.name} als Vorschau anzeigen`" :disabled="documentBusy" @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)" />
<UButton icon="i-heroicons-archive-box" color="neutral" variant="ghost" :aria-label="`${entry.name} archivieren`" :disabled="documentBusy" @click="openArchive(entry)">Archivieren</UButton>
</div></td>
</tr>
</tbody>
</table>
@@ -522,7 +574,7 @@ onBeforeUnmount(() => {
</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' }">
<UModal v-model:open="previewOpen" :title="previewDocument?.name || 'Dokumentvorschau'" description="Dokument prüfen und mit Ordner und Typ ablegen." :dismissible="!documentBusy" :close="!documentBusy" :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">
@@ -597,6 +649,7 @@ onBeforeUnmount(() => {
</template>
<template #footer>
<template v-if="previewDocument">
<UButton icon="i-heroicons-archive-box" color="neutral" variant="ghost" :disabled="documentBusy" @click="openArchive(previewDocument)">Archivieren</UButton>
<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>
@@ -604,6 +657,17 @@ onBeforeUnmount(() => {
</template>
</UModal>
<UModal v-model:open="archiveOpen" title="Dokument archivieren" description="Das Dokument verschwindet aus der Ablage. Die Originaldatei bleibt im Archiv erhalten." :dismissible="!archiveSaving" :close="!archiveSaving">
<template #body>
<p class="break-all font-medium">{{ archiveTarget?.name }}</p>
<UAlert v-if="archiveError" class="mt-4" :title="archiveError" color="error" variant="subtle" />
</template>
<template #footer>
<UButton color="neutral" variant="outline" :disabled="archiveSaving" @click="archiveOpen = false">Abbrechen</UButton>
<UButton icon="i-heroicons-archive-box" :loading="archiveSaving" :disabled="!archiveTarget || filingSaving" @click="archiveDocument">Archivieren</UButton>
</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">

View File

@@ -167,6 +167,84 @@ test('filing failure preserves the document and selected values for retry', asyn
assert.equal(ui.run('canFile.value'), true)
})
test('archiving needs no classification, waits for confirmation and updates the list, preview and badge', async t => {
const pending = deferred()
const staleList = deferred()
const requests = []
let listings = 0
const ui = setup((url, options) => {
requests.push({ url, options })
if (url.endsWith('/archive')) return pending.promise
if (url.endsWith('/filing-options')) return Promise.resolve({ folders: [], types: [] })
if (url.endsWith('/suggestion')) return Promise.resolve({ status: 'pending', suggestion: null })
if (++listings === 1) return staleList.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('openPreview(documents.value[0])')
await settle()
assert.equal(ui.run('canFile.value'), false)
ui.run('openArchive(documents.value[0])')
assert.equal(requests.some(request => request.url.endsWith('/archive')), false)
const listing = ui.run('load(true)')
const saving = ui.run('archiveDocument()')
await ui.run('archiveDocument()')
await ui.run('fileDocument()')
assert.equal(requests.filter(request => request.url.endsWith('/archive')).length, 1)
assert.equal(requests.some(request => request.url.endsWith('/file')), false)
assert.equal(ui.run('documents.value.length'), 1)
pending.resolve({ id: 'scan', archived: true })
await saving
staleList.resolve({ documents: [{ id: 'scan' }], total: 51 })
await listing
assert.equal(requests.find(request => request.url.endsWith('/archive')).options.method, 'POST')
assert.equal(ui.run('archiveOpen.value'), false)
assert.equal(ui.run('previewOpen.value'), false)
assert.equal(ui.run('documents.value[0].id'), 'remaining')
assert.equal(ui.run('total.value'), 50)
assert.equal(ui.run('page.value'), 1)
assert.equal(ui.countRefreshes(), 1)
})
test('archive cancellation and failure preserve the document and allow a retry from the list', async t => {
let calls = 0
const ui = setup(async () => { calls++; throw { data: { message: 'Archivieren fehlgeschlagen' } } })
t.after(ui.close)
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1; openArchive(documents.value[0])')
await settle()
ui.run('archiveOpen.value = false')
await settle()
assert.equal(ui.run('archiveTarget.value'), null)
assert.equal(calls, 0)
ui.run('openArchive(documents.value[0])')
await ui.run('archiveDocument()')
assert.equal(ui.run('archiveError.value'), 'Archivieren fehlgeschlagen')
assert.equal(ui.run('archiveOpen.value'), true)
assert.equal(ui.run('archiveSaving.value'), false)
assert.equal(ui.run('documents.value[0].id'), 'scan')
assert.equal(ui.run('total.value'), 1)
assert.equal(ui.countRefreshes(), 0)
})
test('archive responses from a previous tenant or unmounted page cannot update the current view', async t => {
for (const action of ['tenant', 'unmount']) {
const pending = deferred()
const ui = setup(url => url.endsWith('/archive') ? pending.promise : Promise.resolve({ documents: [{ id: 'tenant-2' }], total: 1 }))
t.after(ui.close)
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1; openArchive(documents.value[0])')
const saving = ui.run('archiveDocument()')
if (action === 'tenant') ui.auth.activeTenant = '2'
else ui.close()
await settle()
pending.resolve({ id: 'scan', archived: true })
await saving
assert.equal(ui.run('documents.value[0].id'), action === 'tenant' ? 'tenant-2' : 'scan')
assert.equal(ui.countRefreshes(), 0)
if (action === 'tenant') assert.equal(ui.run('archiveOpen.value'), false)
}
})
test('tenant switching discards pending folder options and filing responses', async t => {
for (const stage of ['options', 'save']) {
const pending = deferred()