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:
@@ -1657,3 +1657,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-08] Enable Banking's bank_transaction_code object ({description, code, sub_code}) is flattened to a string by one helper in the connect-contract file (normalizeBankTransactionCode: code, code/sub_code, else description), applied by both producers (Connect's normalizeBookedTransaction, the ledger's convertTransaction), and the wire schema stays z.string().nullable(). Rejected: widening the contract to string | object and normalizing only in the ledger's sync.ts. It keeps the wire type dishonest, leaves Connect's own label derivation reading an object, and still needs the direct-path fix, because that path had been writing the object's JSON text into transactions.bank_transaction_code for 78 companies since 2026-08-09 while Connect failed the same type lie loudly on every canary sync from 2026-09-03 (Capstone support case, 2026-09-07). The helper lives in the contract despite its "shape, never behaviour" rule because the two repos already mirror that file byte for byte; a copy per producer is the drift that caused the outage. Repair migration rewrites the stored JSON text with the same rule and nothing else (no transaction_method re-derivation).
|
||||
[2026-09-07] PR #2397 skeptic + review pass: the reclaim is refused while any of its invoices sits in a later live begäran (avslag → new file is Skatteverket's retry; booking the refused share onto the customer meanwhile would clear 1513 twice), a reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed, and a storno of the reclaim voucher syncs the invoices and the begäran back (lib/invoices/rot-rut-reclaim-reversal.ts, hooked into reverseEntry next to the payment sync). Per-invoice reopen goes through one idempotent RPC (apply_rot_rut_reclaim_invoice, item marker + invoice row in one transaction) so a failure after the voucher is resumable instead of stuck at ALREADY_DONE. Crediting an invoice with a reclaimed share is refused (reverse the reclaim first): the credit note reverses the issue-time 1510/1513 split. Declined: CONCURRENTLY for the partial unique index (Supabase migrations run in one transaction; same shape as the shipped rot_rut_payout index in 20260904021000, partial predicate on a source_type that few rows match).
|
||||
[2026-09-08] PR #2397 review cycle 3: the reclaim RPCs own the accounting values. apply_rot_rut_reclaim_invoice takes only the refused share and validates it against the locked item, request and invoice (never above the item's requested amount, the beslut's refused total, or the 1513 headroom), then derives remaining_amount and status from the same formula as the INSERT guard (rot_rut_customer_outstanding); revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher and the request link is cleared only after every leg succeeded. Reason: a SECURITY INVOKER function that accepted caller-supplied remaining/status was an unchecked accounting write for any writer-role member (CWE-862). The 20260907160300 signature is dropped in 20260907160400 rather than edited: the preview branch had already applied it.
|
||||
[2026-09-08] Invoice list gets an 'Ej skickade' view and the PDF download on an unissued document asks first (#2399): both reports came from the same hidden state, a finalized invoice with an F-number whose DB status is still 'draft'. Chosen: split the status in the UI (lib/invoices/invoice-list-tabs.ts, one predicate for rows, counts and sections) and gate the download with a soft dialog whose primary action is the existing manual mark-sent (and book) path, then download. Rejected: a real 'issued' status in the DB (touches MCP, v1 API, reports and SIE for a distinction invoice_number already carries); removing the UTKAST stamp from numbered drafts (an unbooked invoice is not issued, the stamp is right, the flow around it was wrong); naming the tab 'Godkända' as the user asked (there is no attest step, so the label would promise one; ?status=godkanda aliases to the view).
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -17,6 +17,7 @@ import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { proposeSendLines } from '@/lib/bookkeeping/propose-send-lines'
|
||||
@@ -48,7 +49,15 @@ interface SendInvoiceDialogProps {
|
||||
invoice: InvoiceWithRelations
|
||||
/** 'email' sends via email, 'manual' marks as sent without email */
|
||||
mode: 'email' | 'manual'
|
||||
onSuccess: () => void
|
||||
/** `partial`: the document is sent but a follow-up step (PDF archive,
|
||||
* periodisering, delivery history) failed and the toast says so. */
|
||||
onSuccess: (result?: { partial: boolean }) => void
|
||||
/**
|
||||
* Follow-up offered on the manual mark-sent toast (#2399): the user who
|
||||
* marks an invoice as sent by hand usually wants the issued PDF next, to
|
||||
* mail it themselves. Omitted when the caller already downloads it.
|
||||
*/
|
||||
manualSuccessAction?: { label: string; onClick: () => void }
|
||||
}
|
||||
|
||||
export default function SendInvoiceDialog({
|
||||
@@ -57,6 +66,7 @@ export default function SendInvoiceDialog({
|
||||
invoice,
|
||||
mode,
|
||||
onSuccess,
|
||||
manualSuccessAction,
|
||||
}: SendInvoiceDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
@@ -386,7 +396,7 @@ export default function SendInvoiceDialog({
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
onSuccess()
|
||||
onSuccess({ partial: !!data.partial })
|
||||
|
||||
if (mode === 'email') {
|
||||
onOpenChange(false)
|
||||
@@ -431,6 +441,20 @@ export default function SendInvoiceDialog({
|
||||
: shouldBookOnIssue
|
||||
? t('mark_success_voucher_created')
|
||||
: undefined,
|
||||
// A partial success asks the user to check the bookkeeping first;
|
||||
// the PDF offer would pull attention away from that.
|
||||
...(manualSuccessAction && !data.partial
|
||||
? {
|
||||
action: (
|
||||
<ToastAction
|
||||
altText={manualSuccessAction.label}
|
||||
onClick={manualSuccessAction.onClick}
|
||||
>
|
||||
{manualSuccessAction.label}
|
||||
</ToastAction>
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { draftDownloadDecision } from '../draft-download-decision'
|
||||
|
||||
describe('draftDownloadDecision', () => {
|
||||
it('downloads issued documents without asking', () => {
|
||||
expect(draftDownloadDecision({ status: 'sent', invoice_number: 'F-1' })).toBe('download')
|
||||
expect(draftDownloadDecision({ status: 'paid', invoice_number: 'F-1' })).toBe('download')
|
||||
expect(draftDownloadDecision({ status: 'overdue', invoice_number: 'F-1' })).toBe('download')
|
||||
})
|
||||
|
||||
it('offers to issue any numbered draft: the renderer stamps every draft kind', () => {
|
||||
expect(draftDownloadDecision({ status: 'draft', invoice_number: 'F-1' })).toBe('offer_issue')
|
||||
expect(
|
||||
draftDownloadDecision({ status: 'draft', invoice_number: 'O-1', document_type: 'quote' }),
|
||||
).toBe('offer_issue')
|
||||
expect(
|
||||
draftDownloadDecision({ status: 'draft', invoice_number: 'P-1', document_type: 'proforma' }),
|
||||
).toBe('offer_issue')
|
||||
expect(
|
||||
draftDownloadDecision({
|
||||
status: 'draft',
|
||||
invoice_number: 'FS-1',
|
||||
document_type: 'delivery_note',
|
||||
}),
|
||||
).toBe('offer_issue')
|
||||
})
|
||||
|
||||
it('only warns for an unnumbered draft: there is nothing to issue yet', () => {
|
||||
expect(draftDownloadDecision({ status: 'draft', invoice_number: null })).toBe('confirm_draft')
|
||||
expect(draftDownloadDecision({ status: 'draft', invoice_number: '' })).toBe('confirm_draft')
|
||||
})
|
||||
|
||||
it('leaves self-billed invoices alone: there is no own PDF to issue', () => {
|
||||
expect(
|
||||
draftDownloadDecision({ status: 'draft', invoice_number: null, is_self_billed: true }),
|
||||
).toBe('download')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
INVOICE_LIST_TABS,
|
||||
isUnsentNumberedInvoice,
|
||||
matchesInvoiceListTab,
|
||||
parseInvoiceListTab,
|
||||
type InvoiceListTab,
|
||||
} from '../invoice-list-tabs'
|
||||
|
||||
type Row = Parameters<typeof matchesInvoiceListTab>[0]
|
||||
|
||||
function row(overrides: Partial<Row> & { status: Row['status'] }): Row {
|
||||
return {
|
||||
invoice_number: null,
|
||||
credited_invoice_id: null,
|
||||
document_type: 'invoice',
|
||||
is_self_billed: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function tabsFor(invoice: Row): InvoiceListTab[] {
|
||||
return INVOICE_LIST_TABS.filter((tab) => matchesInvoiceListTab(invoice, tab))
|
||||
}
|
||||
|
||||
describe('isUnsentNumberedInvoice', () => {
|
||||
it('is the numbered draft faktura only', () => {
|
||||
expect(isUnsentNumberedInvoice(row({ status: 'draft', invoice_number: 'F-12' }))).toBe(true)
|
||||
expect(isUnsentNumberedInvoice(row({ status: 'draft' }))).toBe(false)
|
||||
expect(isUnsentNumberedInvoice(row({ status: 'sent', invoice_number: 'F-12' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes credit notes, self-billed invoices and other document kinds', () => {
|
||||
expect(
|
||||
isUnsentNumberedInvoice(
|
||||
row({ status: 'draft', invoice_number: 'F-13', credited_invoice_id: 'orig' }),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isUnsentNumberedInvoice(row({ status: 'draft', invoice_number: 'F-14', is_self_billed: true })),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isUnsentNumberedInvoice(row({ status: 'draft', invoice_number: 'O-1', document_type: 'quote' })),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesInvoiceListTab', () => {
|
||||
it('splits DB status draft into unsent (numbered) and draft (unnumbered)', () => {
|
||||
expect(tabsFor(row({ status: 'draft', invoice_number: 'F-12' }))).toEqual(['all', 'unsent'])
|
||||
expect(tabsFor(row({ status: 'draft' }))).toEqual(['all', 'draft'])
|
||||
})
|
||||
|
||||
it('keeps sent and overdue invoices in the unpaid view, never in unsent', () => {
|
||||
expect(tabsFor(row({ status: 'sent', invoice_number: 'F-12' }))).toEqual(['all', 'unpaid'])
|
||||
expect(tabsFor(row({ status: 'overdue', invoice_number: 'F-12' }))).toEqual([
|
||||
'all',
|
||||
'unpaid',
|
||||
'overdue',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not put draft credit notes or drafts of other kinds in unsent or draft', () => {
|
||||
expect(
|
||||
tabsFor(row({ status: 'draft', invoice_number: 'F-13', credited_invoice_id: 'orig' })),
|
||||
).toEqual(['all', 'credit'])
|
||||
expect(tabsFor(row({ status: 'draft', document_type: 'quote' }))).toEqual(['all', 'quote'])
|
||||
expect(tabsFor(row({ status: 'draft', document_type: 'proforma' }))).toEqual([
|
||||
'all',
|
||||
'proforma',
|
||||
])
|
||||
expect(tabsFor(row({ status: 'draft', document_type: 'delivery_note' }))).toEqual([
|
||||
'all',
|
||||
'delivery_note',
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a missing document_type as a faktura', () => {
|
||||
expect(
|
||||
tabsFor({ status: 'draft', invoice_number: 'F-12', credited_invoice_id: null }),
|
||||
).toEqual(['all', 'unsent'])
|
||||
})
|
||||
|
||||
it('keeps cancelled rows out of every view but cancelled', () => {
|
||||
expect(tabsFor(row({ status: 'cancelled', invoice_number: 'F-12' }))).toEqual(['cancelled'])
|
||||
expect(
|
||||
tabsFor(row({ status: 'cancelled', invoice_number: 'F-13', credited_invoice_id: 'orig' })),
|
||||
).toEqual(['cancelled'])
|
||||
})
|
||||
|
||||
it('counts every non-cancelled faktura in exactly one of unsent, draft, unpaid, paid', () => {
|
||||
const rows = [
|
||||
row({ status: 'draft' }),
|
||||
row({ status: 'draft', invoice_number: 'F-1' }),
|
||||
row({ status: 'sent', invoice_number: 'F-2' }),
|
||||
row({ status: 'overdue', invoice_number: 'F-3' }),
|
||||
row({ status: 'paid', invoice_number: 'F-4' }),
|
||||
]
|
||||
const buckets: InvoiceListTab[] = ['unsent', 'draft', 'unpaid', 'paid']
|
||||
for (const invoice of rows) {
|
||||
expect(buckets.filter((tab) => matchesInvoiceListTab(invoice, tab))).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseInvoiceListTab', () => {
|
||||
it('accepts tab ids and the documented aliases', () => {
|
||||
expect(parseInvoiceListTab('unsent')).toBe('unsent')
|
||||
expect(parseInvoiceListTab('drafts')).toBe('draft')
|
||||
expect(parseInvoiceListTab('godkanda')).toBe('unsent')
|
||||
expect(parseInvoiceListTab('approved')).toBe('unsent')
|
||||
})
|
||||
|
||||
it('rejects unknown values', () => {
|
||||
expect(parseInvoiceListTab('bogus')).toBeNull()
|
||||
expect(parseInvoiceListTab(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
/**
|
||||
* What "Ladda ner PDF" should do for a document that is not yet issued.
|
||||
*
|
||||
* The renderer stamps every status='draft' document "UTKAST: inte en giltig
|
||||
* faktura". That stamp is correct: an unbooked invoice is not issued. The
|
||||
* problem (#2399) is that nothing said so before the file was on disk, and
|
||||
* the stamped PDF was mailed to customers by mistake. So the download asks
|
||||
* first, and offers the path that produces the real document.
|
||||
*
|
||||
* - `download`: issued (or a document kind with no issue step); save as is.
|
||||
* - `offer_issue`: numbered, not sent: offer "Markera som skickad (och
|
||||
* bokför) och ladda ner", the same action as the page's primary button.
|
||||
* - `confirm_draft`: no number yet: nothing to issue, only a warning that the
|
||||
* file is a stamped draft. Granska & skapa is the way to a number.
|
||||
*/
|
||||
export type DraftDownloadDecision = 'download' | 'offer_issue' | 'confirm_draft'
|
||||
|
||||
type DecisionInvoice = Pick<Invoice, 'status' | 'invoice_number'> & {
|
||||
document_type?: string | null
|
||||
is_self_billed?: boolean | null
|
||||
}
|
||||
|
||||
export function draftDownloadDecision(invoice: DecisionInvoice): DraftDownloadDecision {
|
||||
if (invoice.status !== 'draft') return 'download'
|
||||
// Self-billed: the counterparty's document, no own PDF to issue.
|
||||
if (invoice.is_self_billed) return 'download'
|
||||
// Every other kind (faktura, kreditfaktura, offert, proforma, följesedel)
|
||||
// is stamped while status is draft; the page picks the issue action per
|
||||
// kind (send dialog, or the plain status flip for följesedlar).
|
||||
return invoice.invoice_number ? 'offer_issue' : 'confirm_draft'
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
/**
|
||||
* The invoice list's status views. One predicate for the visible rows, the
|
||||
* per-view counts and the status sections, so the three can never drift.
|
||||
*
|
||||
* `unsent` and `draft` split the DB status 'draft' in two: an invoice that
|
||||
* went through Granska & skapa carries an F-number and is waiting to be sent
|
||||
* or booked (#2399), while an unnumbered draft is still being written.
|
||||
*/
|
||||
export const INVOICE_LIST_MAIN_TABS = ['all', 'unpaid', 'overdue', 'unsent', 'draft'] as const
|
||||
export const INVOICE_LIST_MORE_TABS = [
|
||||
'paid',
|
||||
'proforma',
|
||||
'quote',
|
||||
'delivery_note',
|
||||
'credit',
|
||||
'cancelled',
|
||||
] as const
|
||||
export const INVOICE_LIST_TABS = [...INVOICE_LIST_MAIN_TABS, ...INVOICE_LIST_MORE_TABS] as const
|
||||
export type InvoiceListTab = (typeof INVOICE_LIST_TABS)[number]
|
||||
|
||||
/** ?status= values that are not tab ids: older bookmarks and the words users
|
||||
* reach for ("godkända" is what Visma calls a finalized, unsent invoice). */
|
||||
export const INVOICE_LIST_TAB_ALIASES: Record<string, InvoiceListTab> = {
|
||||
drafts: 'draft',
|
||||
godkanda: 'unsent',
|
||||
approved: 'unsent',
|
||||
}
|
||||
|
||||
export function parseInvoiceListTab(param: string | null): InvoiceListTab | null {
|
||||
if (!param) return null
|
||||
const candidate = INVOICE_LIST_TAB_ALIASES[param] ?? param
|
||||
return (INVOICE_LIST_TABS as readonly string[]).includes(candidate)
|
||||
? (candidate as InvoiceListTab)
|
||||
: null
|
||||
}
|
||||
|
||||
type ListInvoice = Pick<Invoice, 'status' | 'invoice_number' | 'credited_invoice_id'> & {
|
||||
document_type?: string | null
|
||||
is_self_billed?: boolean | null
|
||||
}
|
||||
|
||||
function documentTypeOf(invoice: ListInvoice): string {
|
||||
return invoice.document_type || 'invoice'
|
||||
}
|
||||
|
||||
/**
|
||||
* A faktura with an F-number whose DB status is still 'draft': finalized but
|
||||
* neither sent nor booked. Self-billed invoices arrive already issued and
|
||||
* credit notes have their own view, so both are excluded.
|
||||
*/
|
||||
export function isUnsentNumberedInvoice(invoice: ListInvoice): boolean {
|
||||
return (
|
||||
invoice.status === 'draft' &&
|
||||
!!invoice.invoice_number &&
|
||||
documentTypeOf(invoice) === 'invoice' &&
|
||||
!invoice.credited_invoice_id &&
|
||||
!invoice.is_self_billed
|
||||
)
|
||||
}
|
||||
|
||||
export function matchesInvoiceListTab(invoice: ListInvoice, tab: InvoiceListTab): boolean {
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = documentTypeOf(invoice)
|
||||
switch (tab) {
|
||||
case 'all':
|
||||
return invoice.status !== 'cancelled'
|
||||
case 'unpaid':
|
||||
return ['sent', 'overdue'].includes(invoice.status) && !isCreditNote && docType === 'invoice'
|
||||
case 'overdue':
|
||||
return invoice.status === 'overdue' && !isCreditNote && docType === 'invoice'
|
||||
case 'unsent':
|
||||
return isUnsentNumberedInvoice(invoice)
|
||||
case 'draft':
|
||||
return (
|
||||
invoice.status === 'draft' &&
|
||||
docType === 'invoice' &&
|
||||
!isCreditNote &&
|
||||
!isUnsentNumberedInvoice(invoice)
|
||||
)
|
||||
case 'paid':
|
||||
return invoice.status === 'paid'
|
||||
case 'credit':
|
||||
return isCreditNote && invoice.status !== 'cancelled'
|
||||
case 'proforma':
|
||||
return docType === 'proforma' && invoice.status !== 'cancelled'
|
||||
case 'quote':
|
||||
return docType === 'quote' && invoice.status !== 'cancelled'
|
||||
case 'delivery_note':
|
||||
return docType === 'delivery_note' && invoice.status !== 'cancelled'
|
||||
case 'cancelled':
|
||||
return invoice.status === 'cancelled'
|
||||
}
|
||||
}
|
||||
@@ -4266,6 +4266,16 @@
|
||||
"pdf_downloaded_title": "PDF downloaded",
|
||||
"pdf_downloaded_with_number": "Invoice {number} has been downloaded",
|
||||
"pdf_downloaded_draft": "The draft has been downloaded",
|
||||
"draft_download_book_title": "The invoice is not posted",
|
||||
"draft_download_book_desc": "The PDF is stamped UTKAST (draft) until the invoice is marked as sent and posted. Post it first if the file is going to the customer.",
|
||||
"draft_download_send_title": "The document is not marked as sent",
|
||||
"draft_download_send_desc": "The PDF is stamped UTKAST (draft) until the document is marked as sent. Mark it as sent first if the file is going to the customer.",
|
||||
"draft_download_unnumbered_title": "The draft has no invoice number",
|
||||
"draft_download_unnumbered_desc": "The PDF is stamped UTKAST and is not a valid invoice. Use Review & create before sending it to the customer.",
|
||||
"draft_download_draft_action": "Download draft",
|
||||
"draft_download_preview_action": "Preview draft",
|
||||
"draft_download_book_action": "Post and download",
|
||||
"draft_download_send_action": "Mark as sent and download",
|
||||
"pdf_download_failed_title": "Could not download PDF",
|
||||
"peppol_downloaded_title": "Peppol XML downloaded",
|
||||
"peppol_downloaded_description": "The XML file passed Accounted's local checks. It has not been sent to Peppol.",
|
||||
@@ -6721,6 +6731,7 @@
|
||||
"tab_all": "All",
|
||||
"tab_unpaid": "Unpaid",
|
||||
"tab_paid": "Paid",
|
||||
"tab_unsent": "Not sent",
|
||||
"tab_draft": "Draft",
|
||||
"tab_proforma": "Proforma",
|
||||
"tab_quote": "Quotes",
|
||||
@@ -6731,6 +6742,8 @@
|
||||
"no_search_results_description": "No invoices match \"{term}\".",
|
||||
"no_category_title": "No invoices in this category",
|
||||
"no_category_description": "Try switching tabs to see more invoices.",
|
||||
"no_unsent_title": "No unsent invoices",
|
||||
"no_unsent_description": "Invoices that have a number but have not been sent or posted yet end up here.",
|
||||
"badge_credit": "Credit",
|
||||
"badge_proforma": "Proforma",
|
||||
"badge_quote": "Quote",
|
||||
@@ -6795,6 +6808,7 @@
|
||||
"group_none": "No grouping",
|
||||
"group_unknown": "Missing",
|
||||
"section_drafts": "Drafts ({count})",
|
||||
"section_unsent": "Not sent ({count})",
|
||||
"section_awaiting": "Awaiting payment ({count})",
|
||||
"section_settled": "Paid and settled ({count})",
|
||||
"coverage_notice": "Invoices before {date} may exist only as posted journal entries and are not shown in this list."
|
||||
|
||||
@@ -4266,6 +4266,16 @@
|
||||
"pdf_downloaded_title": "PDF nedladdad",
|
||||
"pdf_downloaded_with_number": "Faktura {number} har laddats ner",
|
||||
"pdf_downloaded_draft": "Utkastet har laddats ner",
|
||||
"draft_download_book_title": "Fakturan är inte bokförd",
|
||||
"draft_download_book_desc": "PDF:en stämplas UTKAST tills fakturan är markerad som skickad och bokförd. Bokför den först om filen ska skickas till kunden.",
|
||||
"draft_download_send_title": "Dokumentet är inte markerat som skickat",
|
||||
"draft_download_send_desc": "PDF:en stämplas UTKAST tills dokumentet är markerat som skickat. Markera det som skickat först om filen ska skickas till kunden.",
|
||||
"draft_download_unnumbered_title": "Utkastet har inget fakturanummer",
|
||||
"draft_download_unnumbered_desc": "PDF:en stämplas UTKAST och är inte en giltig faktura. Använd Granska & skapa innan den skickas till kunden.",
|
||||
"draft_download_draft_action": "Ladda ner utkast",
|
||||
"draft_download_preview_action": "Visa utkast",
|
||||
"draft_download_book_action": "Bokför och ladda ner",
|
||||
"draft_download_send_action": "Markera som skickad och ladda ner",
|
||||
"pdf_download_failed_title": "Kunde inte ladda ner PDF",
|
||||
"peppol_downloaded_title": "Peppol XML nedladdad",
|
||||
"peppol_downloaded_description": "XML-filen har klarat Accounteds lokala kontroll. Den har inte skickats till Peppol.",
|
||||
@@ -6721,6 +6731,7 @@
|
||||
"tab_all": "Alla",
|
||||
"tab_unpaid": "Obetalda",
|
||||
"tab_paid": "Betalda",
|
||||
"tab_unsent": "Ej skickade",
|
||||
"tab_draft": "Utkast",
|
||||
"tab_proforma": "Proforma",
|
||||
"tab_quote": "Offerter",
|
||||
@@ -6731,6 +6742,8 @@
|
||||
"no_search_results_description": "Inga fakturor matchar \"{term}\".",
|
||||
"no_category_title": "Inga fakturor i denna kategori",
|
||||
"no_category_description": "Prova att byta flik för att se fler fakturor.",
|
||||
"no_unsent_title": "Inga ej skickade fakturor",
|
||||
"no_unsent_description": "Fakturor som har fått nummer men ännu inte skickats eller bokförts hamnar här.",
|
||||
"badge_credit": "Kredit",
|
||||
"badge_proforma": "Proforma",
|
||||
"badge_quote": "Offert",
|
||||
@@ -6795,6 +6808,7 @@
|
||||
"group_none": "Ingen gruppering",
|
||||
"group_unknown": "Saknas",
|
||||
"section_drafts": "Utkast ({count})",
|
||||
"section_unsent": "Ej skickade ({count})",
|
||||
"section_awaiting": "Väntar på betalning ({count})",
|
||||
"section_settled": "Betalda och avslutade ({count})",
|
||||
"coverage_notice": "Fakturor före {date} kan ligga som bokförda verifikat och visas inte i den här listan."
|
||||
|
||||
Reference in New Issue
Block a user