diff --git a/DECISIONS.md b/DECISIONS.md index e6db45b9..19699b40 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -984,6 +984,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index 6faabe36..2797d33f 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -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(null) const [uploadedFiles, setUploadedFiles] = useState([]) + // 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([]) + 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({ { if (!o) { setUploadedFiles([]) + setPickedInboxDocs([]) setShowUploadZone(false) } onOpenChange(o) @@ -586,9 +618,9 @@ export default function QuickReviewDialog({
{t('doc_label')} - {uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && ( + {attachedCount > 0 && ( - {t('doc_attached_count', { count: uploadedFiles.filter((f) => f.status === 'uploaded').length })} + {t('doc_attached_count', { count: attachedCount })} )}
@@ -599,12 +631,49 @@ export default function QuickReviewDialog({ )} {showUploadZone && ( -
+
+ {pickedInboxDocs.map((doc) => ( +
+ + {doc.supplier_name ?? doc.file_name} + +
+ ))} + {/* 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. */} +
)}
@@ -642,6 +711,18 @@ export default function QuickReviewDialog({ + + {/* 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. */} + setInboxPickerOpen(false)} + onSelect={(doc) => + setPickedInboxDocs((prev) => + prev.some((d) => d.document_id === doc.document_id) ? prev : [...prev, doc], + ) + } + />
) diff --git a/messages/en.json b/messages/en.json index d075c013..4c4fd098 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 70e65f90..d5d6647c 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",