fix(ux): book documents directly from inbox + attach existing underlag when booking transactions (#670)
* fix(inbox): re-add Bokför manuellt on unmatched documents Pilot feedback: a document in Dokumentinkorg could not be booked without first matching it to a bank transaction, which is impossible for cash expenses and other entries with no bank movement. The backend (/items/:id/book-direct) and BookDirectlyDialog already support standalone booking — re-expose the button in the unmatched state. The dialog still offers optional transaction selection inside. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transactions): pick existing inbox document when booking manually Pilot feedback: "Bokför manuellt" from a transaction only allowed uploading new files — an already-uploaded underlag from the inbox could not be attached. Add a select mode to InboxDocumentPicker (onSelect prop; journalEntryId now optional) and mount it in TransactionBookingDialog: picked documents are linked after the journal entry is created via /api/documents/{id}/link with inbox_item_id, which also stamps the inbox item as consumed so it drops out of every pending surface. Non-ok link responses now count toward the failure toast (previously only network errors did). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(documents): address PR #670 review — stale preview dialog, JE tenancy check Review findings: - InboxDocumentPicker left the preview dialog floating open when a pick was confirmed from inside it (previewItem was never cleared before onClose; the component stays mounted, so the on-open reset never ran). Clear it in both select and link mode. (greptile) - linkToJournalEntry verified the document's company but trusted the client-supplied journal_entry_id (FK only requires existence). Add an explicit company-scoped journal entry lookup; misses map to the existing DOC_LINK_ENTRY_NOT_FOUND envelope. RLS prevented any data leak either way — this makes the rejection explicit. New regression test covers the cross-tenant case. (compliance-swarm A.8.28) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f7cd1b86e7
commit
cac692e293
@@ -7,10 +7,12 @@ import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, ChevronDown, ChevronUp, Paperclip } from 'lucide-react'
|
||||
import { ArrowUpRight, ArrowDownRight, ChevronDown, ChevronUp, FileText, Inbox, Paperclip, X } from 'lucide-react'
|
||||
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
|
||||
import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPicker'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils'
|
||||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||
@@ -101,47 +103,72 @@ export default function TransactionBookingDialog({
|
||||
const t = useTranslations('tx_booking_dialog')
|
||||
const { toast } = useToast()
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [pickedInboxDocs, setPickedInboxDocs] = useState<AvailableInboxDoc[]>([])
|
||||
const [showUploadZone, setShowUploadZone] = useState(false)
|
||||
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
|
||||
|
||||
if (!transaction) return null
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
|
||||
const handleBooked = async (transactionId: string, journalEntryId: string) => {
|
||||
// Link any uploaded documents to the new journal entry
|
||||
// Link any attached documents to the new journal entry: freshly uploaded
|
||||
// files, and existing inbox documents picked via InboxDocumentPicker. For
|
||||
// picked docs, inbox_item_id stamps the inbox item as consumed so it drops
|
||||
// out of the active inbox — see app/api/documents/[id]/link/route.ts.
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
if (filesToLink.length > 0) {
|
||||
let linkFailCount = 0
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
await fetch(`/api/documents/${file.id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
})
|
||||
} catch {
|
||||
linkFailCount++
|
||||
}
|
||||
}
|
||||
if (linkFailCount > 0) {
|
||||
toast({
|
||||
title: t('doc_link_failed_title'),
|
||||
description: t('doc_link_failed_description', { count: linkFailCount }),
|
||||
variant: 'destructive',
|
||||
let linkFailCount = 0
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${file.id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
})
|
||||
if (!res.ok) linkFailCount++
|
||||
} catch {
|
||||
linkFailCount++
|
||||
}
|
||||
}
|
||||
for (const doc of pickedInboxDocs) {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${doc.document_id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
journal_entry_id: journalEntryId,
|
||||
inbox_item_id: doc.inbox_item_id,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) linkFailCount++
|
||||
} catch {
|
||||
linkFailCount++
|
||||
}
|
||||
}
|
||||
if (linkFailCount > 0) {
|
||||
toast({
|
||||
title: t('doc_link_failed_title'),
|
||||
description: t('doc_link_failed_description', { count: linkFailCount }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setUploadedFiles([])
|
||||
setPickedInboxDocs([])
|
||||
setShowUploadZone(false)
|
||||
onBooked(transactionId, journalEntryId)
|
||||
}
|
||||
|
||||
const attachedCount =
|
||||
uploadedFiles.filter((f) => f.status === 'uploaded').length + pickedInboxDocs.length
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setUploadedFiles([])
|
||||
setPickedInboxDocs([])
|
||||
setShowUploadZone(false)
|
||||
setInboxPickerOpen(false)
|
||||
}
|
||||
onOpenChange(o)
|
||||
}}>
|
||||
@@ -188,9 +215,9 @@ export default function TransactionBookingDialog({
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{t('doc_label')}</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
{attachedCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('doc_attached_count', { count: uploadedFiles.filter((f) => f.status === 'uploaded').length })}
|
||||
{t('doc_attached_count', { count: attachedCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -201,12 +228,55 @@ export default function TransactionBookingDialog({
|
||||
)}
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
{pickedInboxDocs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{pickedInboxDocs.map((doc) => (
|
||||
<div
|
||||
key={doc.document_id}
|
||||
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
|
||||
>
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{doc.supplier_name ?? doc.file_name}
|
||||
</span>
|
||||
{doc.amount != null && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||
{formatCurrency(doc.amount, doc.currency ?? 'SEK')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
aria-label={t('doc_picked_remove')}
|
||||
onClick={() =>
|
||||
setPickedInboxDocs((prev) =>
|
||||
prev.filter((d) => d.document_id !== doc.document_id),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setInboxPickerOpen(true)}
|
||||
>
|
||||
<Inbox className="h-4 w-4 mr-2" />
|
||||
{t('doc_pick_existing')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -226,6 +296,16 @@ export default function TransactionBookingDialog({
|
||||
sourceId={transaction.id}
|
||||
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
|
||||
/>
|
||||
|
||||
<InboxDocumentPicker
|
||||
open={inboxPickerOpen}
|
||||
onClose={() => setInboxPickerOpen(false)}
|
||||
onSelect={(doc) =>
|
||||
setPickedInboxDocs((prev) =>
|
||||
prev.some((d) => d.document_id === doc.document_id) ? prev : [...prev, doc],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user