fix(invoices): Ej skickade view and a prompt before downloading a draft-stamped PDF (#2401)

* fix(invoices): show finalized-but-unsent invoices as their own view and ask before downloading a draft-stamped PDF

Two user reports, one hidden state: an invoice that went through Granska
& skapa has an F-number but its DB status is still 'draft' until it is
marked as sent (and booked). The list lumped those rows under Utkast, and
"Ladda ner PDF" handed out the UTKAST-stamped render with no warning, which
users then mailed to customers.

Why it occurred: the status column carries two meanings for 'draft'
(unnumbered draft vs numbered, unsent invoice) and every surface decided on
its own how to read it. The list badge knew the difference ("Ej skickad"),
the tab predicate and the PDF download did not.

What was simplified: the tab predicate moved out of the page into
lib/invoices/invoice-list-tabs.ts as one function used by the rows, the
per-view counts, the status sections and the row badge, so the four cannot
drift. The ?status= alias parsing collapsed into the same module.

Why this and not the proposed shapes: a real 'issued' status in the DB
would touch MCP, the v1 API, reports and SIE for a distinction that
invoice_number already carries. Removing the UTKAST stamp from numbered
drafts would be wrong: an unbooked invoice is not issued. So the UI splits
the state (Ej skickade view, ?status=unsent, ?status=godkanda alias) and the
download asks first: "Bokför och ladda ner" (or "Markera som skickad och
ladda ner" for cash-method companies and offerter) runs the existing manual
mark-sent dialog and then downloads the issued document; "Ladda ner utkast"
still works. The manual mark-sent toast now offers "Ladda ner PDF" too.

Fixes #2399

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZiqSg2v6XrtaFZyyRK88b

* fix(invoices): skeptic round on the draft-download prompt

Four refutations on 352d4bafc, all confirmed in code:

- Proformas (and any non-faktura) in an accrual company were told "Fakturan
  är inte bokförd" and offered "Bokför och ladda ner"; mark-sent never books
  a proforma. The label predicate is now issuesByBooking = booksOnIssue &&
  isRealInvoice, shared with the existing primary button, which carried the
  same "och bokför" promise on proformas.
- A partial mark-sent success (PDF archive, periodisering or delivery history
  failed) still ran the chained download, and its toast evicted the warning
  (one toast at a time). onSuccess now carries `partial`; the chained
  download is dropped on a partial result, matching the toast action.
- Numbered följesedlar are stamped UTKAST too (pdf-template does not exclude
  them) but the decision skipped the prompt. They now get the prompt; the
  issue action is their own status flip, so updateStatus reports success and
  the download is queued only after it.
- The download queue was a boolean bound to "whatever invoice is mounted";
  the detail pager keeps the page mounted across ArrowLeft/ArrowRight, so a
  step could download the neighbour or leave the queue armed. The queue now
  holds the invoice id and is dropped when a different invoice is shown.

Refs #2399

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZiqSg2v6XrtaFZyyRK88b

* fix(invoices): review pass on PR #2401

CodeRabbit, all confirmed against the code:

- The PDF preview (Visa PDF) bypassed the draft prompt; the browser viewer
  has a save button, so an unwarned preview is an unwarned download. The
  preview now runs the same decision; the prompt's draft button honours the
  original intent ("Visa utkast" opens the viewer, "Ladda ner utkast" saves).
- updateStatus lost its isUpdating reset when both branches started
  returning; moved to finally so a failed refetch after mark-sent does not
  leave the page's buttons disabled.
- A cancelled credit note matched both the Kreditfakturor and Makulerade
  views; the credit view now excludes cancelled rows like every other view.

Declined: the DECISIONS.md date (2026-09-08 is the local date the decision
was recorded; the bot compared against UTC) and the docstring-coverage
warning (not a repo gate).

Refs #2399

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZiqSg2v6XrtaFZyyRK88b

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-08 01:10:14 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 477b59453f
commit 4e8d649b2f
10 changed files with 512 additions and 60 deletions
+147 -8
View File
@@ -34,6 +34,10 @@ import {
type PaymentVoucherRef,
} from '@/lib/invoices/payment-history-gap'
import { effectiveQuoteStatus, isQuoteExpired } from '@/lib/invoices/quote-status'
import {
draftDownloadDecision,
type DraftDownloadDecision,
} from '@/lib/invoices/draft-download-decision'
import type { QuoteStatus } from '@/types'
import {
invoiceDocumentCaveat,
@@ -237,6 +241,36 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
const [showSendDialog, setShowSendDialog] = useState(false)
const [sendDialogMode, setSendDialogMode] = useState<'email' | 'manual'>('email')
// #2399: "Ladda ner PDF" on a document that is not issued yet. The render
// carries the UTKAST stamp, so the page asks before the file exists and
// offers the path to the real document.
const [draftDownloadPrompt, setDraftDownloadPrompt] = useState<Exclude<
DraftDownloadDecision,
'download'
> | null>(null)
// Set when the user picked "... och ladda ner" in that prompt: the manual
// send dialog's success then queues the download of the issued document.
const [downloadAfterSend, setDownloadAfterSend] = useState(false)
// The id of the invoice whose issued PDF is wanted, not a flag: the detail
// pager keeps this component mounted across ArrowLeft/ArrowRight, so a
// queue bound to "whatever is mounted" would download the neighbour.
const [downloadQueued, setDownloadQueued] = useState<string | null>(null)
// The issued document is downloaded once the refetch after mark-sent has
// landed: only then does the source resolver see the new status and the
// archived copy, and the toast names the real document, not the draft.
useEffect(() => {
if (!downloadQueued || !invoice) return
if (invoice.id !== downloadQueued) {
// Paged away before the download ran: drop it rather than hand over
// another invoice's file.
setDownloadQueued(null)
return
}
if (invoice.status === 'draft') return
setDownloadQueued(null)
void downloadPDF()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [downloadQueued, invoice])
const [isConverting, setIsConverting] = useState(false)
const [isCreatingOrder, setIsCreatingOrder] = useState(false)
const [isLoading, setIsLoading] = useState(true)
@@ -666,15 +700,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// Awaited: the acting button keeps its pending state until the page
// reflects the new status (the refetch runs behind the mounted content).
await fetchInvoice()
return true
} catch (error) {
toast({
title: t('status_update_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
return false
} finally {
setIsUpdating(false)
}
setIsUpdating(false)
}
function openSendDialog(mode: 'email' | 'manual') {
@@ -914,9 +950,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
})
}
async function downloadPDF() {
async function downloadPDF(options?: { asDraft?: boolean }) {
if (!invoice) return
setPdfArchiveIssue(null)
// Not issued yet: the render is stamped UTKAST. Ask before the file
// exists (#2399); `asDraft` is the prompt's own "Ladda ner utkast".
const decision = draftDownloadDecision(invoice)
if (decision !== 'download' && !options?.asDraft) {
setPdfIntent('download')
setDraftDownloadPrompt(decision)
return
}
await runInvoiceDownload(
resolveInvoicePdfSource({
invoiceId: invoice.id,
@@ -1233,9 +1277,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
}
function previewPDF() {
function previewPDF(options?: { asDraft?: boolean }) {
if (!invoice) return
setPdfArchiveIssue(null)
// Same guard as the download: the browser viewer has its own save
// button, so an unwarned preview is an unwarned download (#2399).
const decision = draftDownloadDecision(invoice)
if (decision !== 'download' && !options?.asDraft) {
setPdfIntent('preview')
setDraftDownloadPrompt(decision)
return
}
void runInvoicePreview(
resolveInvoicePdfSource({
invoiceId: invoice.id,
@@ -1444,6 +1496,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const booksOnIssue = isCreditNote
? !!originalInvoice && creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
: accountingMethod === 'accrual' && !deferInvoiceBooking
// Only a faktura (or kreditfaktura) books at issue. Proformas, offerter and
// följesedlar are marked sent without a verifikat in every accounting
// method (issue-and-book-invoice: isRealInvoice), so their labels must not
// promise one.
const issuesByBooking = booksOnIssue && isRealInvoice
// #967: sent under deferred booking; ekonomi books the revenue verifikat
// from here afterwards.
const canBookAfterwards =
@@ -1808,7 +1865,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Mail className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t(booksOnIssue && !isQuote ? 'send_via_email_and_book' : 'send_via_email')}
{t(issuesByBooking ? 'send_via_email_and_book' : 'send_via_email')}
</Button>
) : (
<Button
@@ -1817,7 +1874,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Send className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t(booksOnIssue && !isQuote ? 'mark_sent_and_book' : 'mark_as_sent')}
{t(issuesByBooking ? 'mark_sent_and_book' : 'mark_as_sent')}
</Button>
)
)}
@@ -2680,6 +2737,67 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</DialogContent>
</Dialog>
{/* #2399: the document is not issued, so its PDF carries the UTKAST
stamp. Say so before the file exists and offer the path to the real
document: the same manual mark-sent (and book) as the primary
button. "Ladda ner utkast" stays a working choice (soft guard). */}
<Dialog
open={draftDownloadPrompt !== null}
onOpenChange={(open) => {
if (!open) setDraftDownloadPrompt(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{draftDownloadPrompt === 'confirm_draft'
? t('draft_download_unnumbered_title')
: t(issuesByBooking ? 'draft_download_book_title' : 'draft_download_send_title')}
</DialogTitle>
<DialogDescription>
{draftDownloadPrompt === 'confirm_draft'
? t('draft_download_unnumbered_desc')
: t(issuesByBooking ? 'draft_download_book_desc' : 'draft_download_send_desc')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDraftDownloadPrompt(null)}>
{tCommon('cancel')}
</Button>
<Button
variant={draftDownloadPrompt === 'offer_issue' && canWrite ? 'secondary' : 'default'}
onClick={() => {
setDraftDownloadPrompt(null)
// Honour what was asked for: open the viewer, or save the file.
if (pdfIntent === 'preview') previewPDF({ asDraft: true })
else void downloadPDF({ asDraft: true })
}}
>
{t(pdfIntent === 'preview' ? 'draft_download_preview_action' : 'draft_download_draft_action')}
</Button>
{draftDownloadPrompt === 'offer_issue' && canWrite && (
<Button
disabled={isUpdating}
onClick={async () => {
setDraftDownloadPrompt(null)
if (isDeliveryNote) {
// Följesedel: no send dialog, the primary button is a
// plain status flip. Queue only once it succeeded.
const invoiceId = invoice.id
if (await updateStatus('sent')) setDownloadQueued(invoiceId)
return
}
setDownloadAfterSend(true)
openSendDialog('manual')
}}
>
{t(issuesByBooking ? 'draft_download_book_action' : 'draft_download_send_action')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* The archived PDF the customer received could not be produced. Nothing
has been downloaded at this point: a re-render is a different
document, so the user chooses it deliberately or not at all. */}
@@ -2738,10 +2856,31 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{invoice && (
<SendInvoiceDialog
open={showSendDialog}
onOpenChange={setShowSendDialog}
onOpenChange={(open) => {
setShowSendDialog(open)
// Cancelled: the download the user chained onto it is off too.
if (!open) setDownloadAfterSend(false)
}}
invoice={invoice}
mode={sendDialogMode}
onSuccess={() => fetchInvoice()}
onSuccess={(result) => {
if (downloadAfterSend) {
setDownloadAfterSend(false)
// A partial success (archive or periodisering failed) shows a
// warning to act on; a download toast would evict it (one toast
// at a time), so the chained download is dropped there.
if (!result?.partial) setDownloadQueued(invoice.id)
}
fetchInvoice()
}}
// The toast's "Ladda ner PDF" queues the download rather than
// starting it: the invoice in this closure is still the draft, and
// the id pins it to this invoice if the user pages on meanwhile.
manualSuccessAction={
downloadAfterSend || invoice.is_self_billed
? undefined
: { label: t('download_pdf'), onClick: () => setDownloadQueued(invoice.id) }
}
/>
)}
+26 -50
View File
@@ -33,6 +33,13 @@ import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { effectiveQuoteStatus } from '@/lib/invoices/quote-status'
import { matchesInvoiceSearch } from '@/lib/invoices/invoice-search'
import {
INVOICE_LIST_TABS,
isUnsentNumberedInvoice,
matchesInvoiceListTab,
parseInvoiceListTab,
type InvoiceListTab,
} from '@/lib/invoices/invoice-list-tabs'
import {
fetchInvoiceRegisterCoverage,
NO_INVOICE_REGISTER_COVERAGE,
@@ -96,49 +103,20 @@ const INITIAL_VISIBLE_ROWS = 100
const CREATE_MODES = ['faktura', 'aterkommande', 'sjalvfaktura'] as const
// Main views (concept seg) and the low-frequency views behind "Fler …".
const SEG_TABS = ['all', 'unpaid', 'overdue', 'draft'] as const
const MORE_TABS = ['paid', 'proforma', 'quote', 'delivery_note', 'credit', 'cancelled'] as const
const ALL_TABS = [...SEG_TABS, ...MORE_TABS]
type ListTab = (typeof SEG_TABS)[number] | (typeof MORE_TABS)[number]
// The one status predicate: both the visible rows and the per-view counts in
// the ContextPicker go through it, so the annotation always matches what the
// view will show.
function matchesListTab(invoice: Invoice, tab: ListTab): boolean {
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
return (
(tab === 'all' && invoice.status !== 'cancelled') ||
(tab === 'unpaid' &&
['sent', 'overdue'].includes(invoice.status) &&
!isCreditNote &&
docType === 'invoice') ||
(tab === 'overdue' &&
invoice.status === 'overdue' &&
!isCreditNote &&
docType === 'invoice') ||
(tab === 'draft' &&
invoice.status === 'draft' &&
docType === 'invoice' &&
!isCreditNote) ||
(tab === 'paid' && invoice.status === 'paid') ||
(tab === 'credit' && isCreditNote) ||
(tab === 'proforma' && docType === 'proforma' && invoice.status !== 'cancelled') ||
(tab === 'quote' && docType === 'quote' && invoice.status !== 'cancelled') ||
(tab === 'delivery_note' &&
docType === 'delivery_note' &&
invoice.status !== 'cancelled') ||
(tab === 'cancelled' && invoice.status === 'cancelled')
)
}
// The status views and their one predicate live in lib/invoices/invoice-list-tabs:
// the visible rows, the per-view counts in the ContextPicker and the status
// sections all go through it, so the annotation always matches what the view
// will show.
const ALL_TABS = INVOICE_LIST_TABS
type ListTab = InvoiceListTab
const matchesListTab = matchesInvoiceListTab
// Row grouping: sections in the table body. 'none' (the flat list) is the
// default; the other modes are opt-in via ?group= and mirror the
// supplier-invoices layout (payment queue as its own section).
const GROUP_MODES = ['status', 'customer', 'month', 'none'] as const
type GroupMode = (typeof GROUP_MODES)[number]
const STATUS_SECTION_ORDER = ['drafts', 'awaiting', 'settled'] as const
const STATUS_SECTION_ORDER = ['drafts', 'unsent', 'awaiting', 'settled'] as const
/** Sentinel bucket for rows with no customer or no date. Not a display
* string: the header renders t('group_unknown') for it. */
const UNKNOWN_GROUP_KEY = 'unknown'
@@ -149,11 +127,12 @@ const GROUP_LABEL_KEYS: Record<GroupMode, string> = {
none: 'group_none',
}
type StatusGroup = 'drafts' | 'awaiting' | 'settled'
type StatusGroup = (typeof STATUS_SECTION_ORDER)[number]
/** Sections reuse the tab predicates so the two can never drift: what the
* Utkast and Obetalda tabs show is what the sections bucket. */
* Utkast, Ej skickade and Obetalda tabs show is what the sections bucket. */
function statusGroupOf(invoice: Invoice): StatusGroup {
if (matchesListTab(invoice, 'draft')) return 'drafts'
if (matchesListTab(invoice, 'unsent')) return 'unsent'
if (matchesListTab(invoice, 'unpaid')) return 'awaiting'
return 'settled'
}
@@ -177,6 +156,7 @@ const TAB_LABEL_KEYS: Record<ListTab, string> = {
all: 'tab_all',
unpaid: 'tab_unpaid',
overdue: 'tab_overdue',
unsent: 'tab_unsent',
draft: 'tab_draft',
paid: 'tab_paid',
proforma: 'tab_proforma',
@@ -269,11 +249,7 @@ export default function InvoicesPage() {
const [activeTab, setActiveTab] = useState<ListTab>(() => {
// Deep links from the worklist and older bookmarks: ?status= / ?tab=.
const param = searchParams.get('status') ?? searchParams.get('tab')
const alias: Record<string, ListTab> = { drafts: 'draft' }
const candidate = param ? (alias[param] ?? (param as ListTab)) : null
return candidate && ALL_TABS.includes(candidate as never)
? (candidate as ListTab)
: 'all'
return parseInvoiceListTab(param) ?? 'all'
})
const [groupMode, setGroupMode] = useState<GroupMode>(() => {
const param = searchParams.get('group')
@@ -501,6 +477,7 @@ export default function InvoicesPage() {
const count = meta?.count ?? 0
if (groupMode === 'status') {
if (key === 'drafts') return t('section_drafts', { count })
if (key === 'unsent') return t('section_unsent', { count })
if (key === 'awaiting') return t('section_awaiting', { count })
return t('section_settled', { count })
}
@@ -735,10 +712,7 @@ export default function InvoicesPage() {
if (quoteStatus === 'accepted') return { label: t('quote_status_accepted') }
if (quoteStatus === 'open') return { label: t('quote_status_open') }
if (invoice.status === 'draft') {
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
const isUnsent =
!!invoice.invoice_number && docType === 'invoice' && !isCreditNote && !invoice.is_self_billed
return isUnsent
return isUnsentNumberedInvoice(invoice)
? { label: t('status_unsent'), exception: true, variant: 'outline' }
: { label: t('status_draft'), exception: true, variant: 'secondary' }
}
@@ -920,8 +894,10 @@ export default function InvoicesPage() {
) : (
<DataListEmpty
icon={<ReceiptText className="h-6 w-6" />}
title={t('no_category_title')}
description={t('no_category_description')}
title={t(activeTab === 'unsent' ? 'no_unsent_title' : 'no_category_title')}
description={t(
activeTab === 'unsent' ? 'no_unsent_description' : 'no_category_description',
)}
/>
)
) : (