From 2c3ffaf51c29669dd6a77f20dbe982605b6054d4 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 3 Sep 2026 18:56:14 +0200 Subject: [PATCH] feat(invoices): offer proforma where the invoice is created (#2217) (#2254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proforma existed (document_type 'proforma', its own conversion path and views) but the only way to pick it was the collapsed Förval panel inside the editor. A user coming from Fortnox looked for it next to "Ny faktura", did not find it, and concluded the feature was missing. "Ny proformafaktura" is now an entry in the create split button on /invoices, driven by ?proforma=1 exactly like ?quote=1 drives "Ny offert": the URL opens NewInvoiceDialog with the proforma type preselected, the dialog's accessible title says so, and closing the dialog clears the param. Keys added to sv and en. Closes #2217 Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- app/(dashboard)/invoices/page.tsx | 27 ++++++++++++++++++++++-- components/invoices/NewInvoiceDialog.tsx | 6 ++++-- messages/en.json | 2 ++ messages/sv.json | 2 ++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index f21360c3..4357fbbf 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -47,6 +47,7 @@ import { FileInput, FileDown, FileText, + FileClock, } from 'lucide-react' import { StartCard } from '@/components/dashboard/StartCard' import { useCompany } from '@/contexts/CompanyContext' @@ -246,8 +247,12 @@ export default function InvoicesPage() { const copyFromId = searchParams.get('copy') const showNewInvoice = searchParams.has('new') || copyFromId !== null const openSelfBilled = searchParams.has('self') - // ?quote=1 preselects the offert document type ("Ny offert" split entry). + // ?quote=1 preselects the offert document type ("Ny offert" split entry); + // ?proforma=1 the proforma type ("Ny proformafaktura" split entry, #2217: + // proforma existed but only behind the collapsed Förval panel inside the + // editor, so a user coming from Fortnox concluded it did not exist). const openQuote = searchParams.has('quote') + const openProforma = searchParams.has('proforma') const showRotRutPayout = searchParams.has('rot-rut') // Open/close handlers rewrite only their own keys: a hardcoded '/invoices' // would destroy the ?status= view write-back (and any other params) every @@ -264,6 +269,7 @@ export default function InvoicesPage() { p.delete('new') p.delete('self') p.delete('quote') + p.delete('proforma') p.delete('copy') }), { scroll: false }, @@ -286,6 +292,14 @@ export default function InvoicesPage() { }), { scroll: false }, ) + const openNewProforma = () => + router.push( + invoicesUrl((p) => { + p.set('new', '1') + p.set('proforma', '1') + }), + { scroll: false }, + ) const closeRotRutPayout = () => router.replace(invoicesUrl((p) => p.delete('rot-rut')), { scroll: false }) const openRotRutPayout = () => @@ -527,6 +541,15 @@ export default function InvoicesPage() { disabledTitle: t('viewer_disabled_tooltip'), onSelect: () => openNewQuote(), }, + { + key: 'proforma', + label: t('create_proforma'), + icon: FileClock, + description: t('create_proforma_desc'), + disabled: !canWrite, + disabledTitle: t('viewer_disabled_tooltip'), + onSelect: () => openNewProforma(), + }, { key: 'aterkommande', label: t('create_recurring'), @@ -929,7 +952,7 @@ export default function InvoicesPage() { open copyFromId={copyFromId} selfBilled={openSelfBilled} - documentType={openQuote ? 'quote' : undefined} + documentType={openQuote ? 'quote' : openProforma ? 'proforma' : undefined} onOpenChange={(open) => { if (!open) closeNewInvoice() }} diff --git a/components/invoices/NewInvoiceDialog.tsx b/components/invoices/NewInvoiceDialog.tsx index 8b26e977..b4b86b13 100644 --- a/components/invoices/NewInvoiceDialog.tsx +++ b/components/invoices/NewInvoiceDialog.tsx @@ -30,7 +30,7 @@ interface Props { copyFromId?: string | null /** Preselect the självfaktura tab (split-button entry on /invoices). */ selfBilled?: boolean - /** Preselect a document type, e.g. 'quote' ("Ny offert" split-button entry). */ + /** Preselect a document type: 'quote' ("Ny offert") or 'proforma' ("Ny proformafaktura") split-button entries. */ documentType?: InvoiceDocumentType } @@ -146,7 +146,9 @@ export default function NewInvoiceDialog({ ? t('title_copy') : documentType === 'quote' && !selfBilled ? t('title_quote') - : t('title_invoice')} + : documentType === 'proforma' && !selfBilled + ? t('title_proforma') + : t('title_invoice')} {copyFromId ? ( copyLoadFailed ? ( diff --git a/messages/en.json b/messages/en.json index 6fdbf82f..dfb9ef9e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6465,6 +6465,8 @@ "create_self_desc": "Register a self-billing invoice from your customer", "create_quote": "New quote", "create_quote_desc": "Create a quote that can become an invoice", + "create_proforma": "New proforma invoice", + "create_proforma_desc": "Advance invoice that is not booked and can become a real invoice", "bulkbar_selected": "{count, plural, one {invoice selected} other {invoices selected}}", "bulk_select_all": "Select all ({count})", "bulk_clear": "Clear", diff --git a/messages/sv.json b/messages/sv.json index f65a7f92..b92121c0 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6465,6 +6465,8 @@ "create_self_desc": "Registrera en självfaktura från din kund", "create_quote": "Ny offert", "create_quote_desc": "Skapa en offert som kan bli en faktura", + "create_proforma": "Ny proformafaktura", + "create_proforma_desc": "Förhandsfaktura som inte bokförs och kan bli en riktig faktura", "bulkbar_selected": "{count, plural, one {faktura vald} other {fakturor valda}}", "bulk_select_all": "Markera alla ({count})", "bulk_clear": "Rensa",