feat(transactions): pick underlag from the inkorg in quick review (#1620)

* feat(transactions): pick underlag from the inkorg in quick review

The quick-review dialog reached from "Att göra -> Bokföra transaktioner"
only offered a local-file dropzone, so a receipt already sitting in the
underlagsinkorg had to be re-uploaded. That also left the inbox item
unconsumed, leaving a duplicate in "Underlag att hantera".

Wire InboxDocumentPicker (select mode) into the Underlag section, next to
the upload zone, mirroring TransactionBookingDialog. Picked documents are
held until the booking returns a verifikat, then linked through the
existing linkDocuments call with their inbox_item_id, which stamps the
inbox item as consumed.

Picks reset on close and when the dialog moves to another transaction, so
a document picked for one row can never attach to another row's verifikat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(transactions): clear inbox picks when the underlag link fails

The partial-failure branch returned before the cleanup, so a pick that had
already linked (and been consumed) stayed in state and would be re-linked
on the next transaction the reused dialog opened. Picks are safe to drop:
one that failed to link was never stamped, so it is still in the inkorg.

Also record in DECISIONS.md that no "saknar underlag" predicate keys on
transactions.document_id, which is what made leaving the pin off safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(transactions): lock the inbox pick controls while booking

handleConfirm captures pickedInboxDocs when it starts, so a document
picked after the booking request went out was never linked, and the
cleanup on completion then removed it from the list with no error. The
pick and remove controls now follow the rest of the dialog and disable
while isProcessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-14 12:47:22 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 0938646693
commit 3036c3d879
4 changed files with 94 additions and 8 deletions
+1
View File
@@ -984,6 +984,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] Radius ladder locked (convention 16): 4 tiers by role (pill toolbar controls / rounded-xl overlays / rounded-lg surfaces / rounded-sm leaves); rounded-md, bare rounded, rounded-2xl and rounded-[Npx] retired app-wide, hard-failed by check:guards off-ladder-radius. Before: 7 radii in circulation with no rule; one toolbar row on /transactions mixed 4 shape languages.
[2026-08-13] Toolbar shape language: pills won over "inputs stay rectangles". Search in a page toolbar is a pill (ToolbarSearch, h-8) matching chips/pickers/buttons beside it; the same search inside a dialog or form keeps rounded-lg Input. Rationale: convention 8 already made pickers pill chips and convention 3 made buttons pills, so the rectangle search/segmented were the odd ones out; one row = one shape reads as trust.
[2026-08-13] Dialogs promoted 8px -> 12px (rounded-xl): SettingsModal, slide-over and CommandPalette were already 12px, so dialog.tsx was the overlay-tier outlier, not the rule.
[2026-08-14] QuickReviewDialog inbox picker links picked docs with inbox_item_id but deliberately NOT transaction_id: the dialog's existing upload path never pinned transactions.document_id either, and adding the pin would be a drive-by behaviour change to uploads. Known asymmetry with TransactionBookingDialog, which pins both; revisit as its own change if the underlag indicator matters on rows booked from att-göra. Verified safe on the compliance side: since migration 20260703160000 every "saknar underlag" predicate (verifikat_without_documents, transactions_without_documents, the MCP tools, full-archive-export, the worklist badge) keys on document_attachments.journal_entry_id, never on transactions.document_id, so an unpinned row is never reported as missing underlag.
[2026-08-14] The onboarding branch question ("Var fanns bokföringen innan?") became its own journey step at the existing KLART station (done -> source, same station grammar as momsyn/moms under MOMSEN) instead of a sixth rail station: a 6-point rail crowds the 680px band's 150px label slots, "BOKFÖRINGEN INNAN" would sit next to the existing "BOKFÖRINGEN" station, and mode='add' (which never sees the branch question) would need an asymmetric rail. The done screen ends in a revealed Fortsätt that dispatches DONE_CONTINUE (mode='first' only, reducer-guarded).
[2026-08-14] Migration /preview fetches ALL allowed fiscal years (dropped latestOnly): the connect step's "Hittade X konton och Y verifikationer" renders from /preview's sieStats, not /sie-data's generateImportPreview, so fixing only /sie-data would have left the founder-reported "0 verifikationer" (actual: 4153) on screen. Costs one SIE export per extra year at connect time, the same work /sie-data repeats right after; honest counts won over latency.
[2026-08-14] /sie-data validation stays newest-file-only (not per-file, not on the merged parse): preserves exactly which datasets are accepted today, and validateSIEFile assumes single-file invariants (balance yearIndexes relative to ONE current year) that mergeParsedSIEFiles deliberately does not preserve. Older files' problems still surface per-file at import time.
+89 -8
View File
@@ -10,7 +10,7 @@ import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { linkDocuments, formatFailedDocumentNames } from '@/lib/documents/link-documents'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle, Inbox, FileText, X } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
@@ -23,7 +23,9 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPicker'
import VatTreatmentSelect from './VatTreatmentSelect'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
@@ -91,6 +93,12 @@ export default function QuickReviewDialog({
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
// Underlag already sitting in the inkorg, picked instead of re-uploaded. The
// journal entry does not exist yet at pick time, so these are held here and
// linked (with their inbox_item_id, which consumes the inbox item) once the
// booking returns a verifikat: same select-mode contract TransactionBookingDialog uses.
const [pickedInboxDocs, setPickedInboxDocs] = useState<AvailableInboxDoc[]>([])
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
const [showUploadZone, setShowUploadZone] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
// Mirror of `transaction` so we can patch in a freshly-fetched SEK conversion
@@ -140,6 +148,9 @@ export default function QuickReviewDialog({
setEnrichedTx(transaction)
setRateError(null)
setDims({ ...(counterpartyDefaultDimensions ?? {}) })
// A document picked for the previous row must never follow the dialog to
// the next one: it would attach that underlag to the wrong verifikat.
setPickedInboxDocs([])
// Re-seeding on counterpartyDefaultDimensions alone would clobber in-
// flight edits; the bag only changes together with the transaction.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -228,6 +239,8 @@ export default function QuickReviewDialog({
tx.currency,
tx.exchange_rate
)
const attachedCount =
uploadedFiles.filter((f) => f.status === 'uploaded').length + pickedInboxDocs.length
const isForeign = !!(tx.currency && tx.currency !== 'SEK')
const sekConversionMissing = isForeign && (tx.amount_sek == null || tx.exchange_rate == null)
@@ -283,10 +296,20 @@ export default function QuickReviewDialog({
// is already committed here, so a failed link can only be reported, not
// undone. The parent's "Bokförd" toast must not be the last word when a
// receipt never made it onto the books.
if (journalEntryId && uploadedFiles.length > 0) {
const targets = uploadedFiles
.filter((f) => f.status === 'uploaded' && f.id)
.map((f) => ({ documentId: f.id as string, fileName: f.fileName }))
if (journalEntryId && (uploadedFiles.length > 0 || pickedInboxDocs.length > 0)) {
const targets = [
...uploadedFiles
.filter((f) => f.status === 'uploaded' && f.id)
.map((f) => ({ documentId: f.id as string, fileName: f.fileName })),
// inboxItemId stamps the inbox item as consumed so the underlag drops
// out of "Underlag att hantera" instead of lingering as a duplicate of
// the verifikat it now belongs to: see app/api/documents/[id]/link/route.ts.
...pickedInboxDocs.map((doc) => ({
documentId: doc.document_id,
fileName: doc.supplier_name ?? doc.file_name,
inboxItemId: doc.inbox_item_id,
})),
]
const { failed } = await linkDocuments(targets, journalEntryId)
if (failed.length > 0) {
toast({
@@ -310,11 +333,19 @@ export default function QuickReviewDialog({
// is invisible either way: the toast above, with its open-entry
// action, is the user's actual pointer to the underlag that did not
// attach. The early return just skips the redundant cleanup below.
//
// Picks are still dropped: the dialog instance is reused across rows,
// and a pick that DID link is already consumed, so carrying it into
// the next transaction would re-link a spent document. Nothing is lost
// by clearing, unlike uploadedFiles: an underlag that failed to link
// was never stamped, so it is still sitting in the inkorg to re-pick.
setPickedInboxDocs([])
return
}
}
setUploadedFiles([])
setPickedInboxDocs([])
setShowUploadZone(false)
} catch {
setError(t('generic_error'))
@@ -331,6 +362,7 @@ export default function QuickReviewDialog({
<Dialog open={open} onOpenChange={isProcessing ? undefined : (o) => {
if (!o) {
setUploadedFiles([])
setPickedInboxDocs([])
setShowUploadZone(false)
}
onOpenChange(o)
@@ -586,9 +618,9 @@ export default function QuickReviewDialog({
<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>
@@ -599,12 +631,49 @@ export default function QuickReviewDialog({
)}
</button>
{showUploadZone && (
<div className="px-3 pb-3">
<div className="px-3 pb-3 space-y-2">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
/>
{pickedInboxDocs.map((doc) => (
<div
key={doc.document_id}
className="flex items-center gap-2 rounded-sm bg-muted/50 px-2 py-1.5 text-sm"
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{doc.supplier_name ?? doc.file_name}</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 shrink-0 p-0"
aria-label={t('doc_picked_remove')}
disabled={isProcessing}
onClick={() =>
setPickedInboxDocs((prev) =>
prev.filter((d) => d.document_id !== doc.document_id),
)
}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
{/* Locked while the booking is in flight: handleConfirm captured
pickedInboxDocs when it started, so anything picked now would
never be linked and would then be cleared on completion,
vanishing from the list with no error to explain it. */}
<Button
type="button"
variant="outline"
size="sm"
disabled={isProcessing}
onClick={() => setInboxPickerOpen(true)}
>
<Inbox className="mr-2 h-4 w-4" />
{t('doc_pick_existing')}
</Button>
</div>
)}
</div>
@@ -642,6 +711,18 @@ export default function QuickReviewDialog({
</div>
</div>
</div>
{/* Select mode: the verifikat does not exist yet, so the pick is held in
state and linked in handleConfirm once the booking returns its id. */}
<InboxDocumentPicker
open={inboxPickerOpen}
onClose={() => setInboxPickerOpen(false)}
onSelect={(doc) =>
setPickedInboxDocs((prev) =>
prev.some((d) => d.document_id === doc.document_id) ? prev : [...prev, doc],
)
}
/>
</DialogContent>
</Dialog>
)
+2
View File
@@ -2767,6 +2767,8 @@
"view": "View",
"doc_label": "Receipt",
"doc_attached_count": "{count} attached",
"doc_pick_existing": "Choose from the inbox",
"doc_picked_remove": "Remove document",
"doc_link_failed_title": "Receipt could not be attached",
"doc_link_failed_description": "{count} file(s) could not be linked to the journal entry.",
"doc_link_failed_booked_title": "Booked, but documents are missing",
+2
View File
@@ -2767,6 +2767,8 @@
"view": "Visa",
"doc_label": "Underlag",
"doc_attached_count": "{count} bifogade",
"doc_pick_existing": "Välj från inkorgen",
"doc_picked_remove": "Ta bort underlag",
"doc_link_failed_title": "Underlag kunde inte bifogas",
"doc_link_failed_description": "{count} fil(er) kunde inte länkas till verifikationen.",
"doc_link_failed_booked_title": "Bokförd, men underlag saknas",