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:
co-authored by
Claude Opus 5
parent
0938646693
commit
3036c3d879
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user