feat(invoices): rebuild the invoice editor as the snabbflöde single column (#1654)
* refactor(invoices): extract editor payload builders with parity tests Extract the three near-identical inline payload builders in InvoiceEditor.tsx (handleConfirm, saveDraftData, saveEdit) and the self-billed body mapper into pure functions in lib/invoices/editor-payload.ts. Zero behavioral change: the new lib module carries a 300-case parity suite asserting JSON byte equality against verbatim copies of the legacy inline recipes across the full mode x deduction x dimensions x ore-rounding matrix. This is the byte-compatibility ratchet under the upcoming editor re-layout: the repo renders no components in tests, so the wire bodies are what CI can pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): rebuild the invoice editor as the snabbflöde single column Reshape InvoiceEditor to the approved prototype: one 640px column with uppercase section labels and honest state marks (RequiredMark asterisks, sage check on a picked customer, muted row counts), a dense in-table rows surface with a unified last-row entry (autocomplete over the artikelregister, italic ghost cells, Enter commits free text and lands in the price cell, ArrowDown+Enter commits an article through the same applyArticle side effects), hover-revealed 24px row controls with 40px coarse-pointer targets and per-row aria-labels, a Förval chip line whose collapsed settings re-surface as chips whenever a value deviates from its default (critical in edit/copy so PATCH never round-trips invisible values), a single ochre next-step line (aria-live polite) that doubles as the invalid-submit focus router, and a sticky bottom action bar with the live total: position sticky in both hosts, never fixed, since DialogContent's transform re-anchors fixed children in bare mode. Behavioral deltas, all pre-decided: the primary action is never disabled pre-click for writable users (viewers keep the lock+tooltip treatment); client-side validation failures route focus instead of toasting; genuine field errors stay terracotta and field-adjacent while the two ochre disclosures (taxed-where-performed, labor-only) demote to muted text; committed free-text rows expose a quiet Spara-som-artikel link; the review dialog lists the applied förval (currency, öre rounding, payment-link state); a freshly committed row gets a brief background settle that collapses under prefers-reduced-motion. ArticleCombobox gains the missing combobox ARIA (listbox/option roles, aria-controls, aria-activedescendant only after explicit arrowing). New pure module invoice-editor-flow.ts pins the next-step priority order, the Förval chip derivation and the suggestion filter with unit tests. All payload builders, submit targets and the VAT baseline refs are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): editor review nits: orphaned keys, housing gate, listbox ARIA Three review findings on the snabbflode editor: - Delete 13 orphaned invoice_editor keys from both message files (subtitle_*, add_row, remove_row, remove_row_aria, details_card_title, save_as_draft_short, validation_toast_*, delivery_date_placeholder); each verified unused on the branch, sv/en parity kept. - Gate the housing next-step on a claimed deduction amount so it matches the ROT/RUT claim card's mount condition: a ROT-flagged line with a zero amount mounts no card, and the ochre link would try to focus an unmounted field. Extracted as deriveRequiresHousing in the flow module with a test proven to fail on the old gate. - Move the entry-row popover hint out of the role=listbox element (listbox children must be options) into a sibling inside the absolute wrapper, referenced via aria-describedby on the combobox input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): drop the in-editor faktura/sjalvfaktura tabs The Ny faktura split button already chooses the mode (?self=1); a second switcher inside the editor was double steering. The mode is now fixed for the editor's lifetime and the heading (Registrera sjalvfaktura) carries the distinction. Orphaned tab keys removed from both message files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): wrap sticky-bar actions so they fit small viewports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): stop dialog grid item overflowing small viewports min-w-0 on the editor root: DialogContent is display:grid, so the row grid's min-w otherwise forces the column past narrow screens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): lift assistant FAB above the standalone editor's action bar The rebuilt editor introduces the first page-level sticky bottom bar; the assistant FAB (fixed, z-30) covered its Spara/Granska buttons on the /invoices/[id]/edit page. The editor now sets body[data-page-bottom-bar] in non-bare mode and AgentTrigger lifts to bottom-20 when it is present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -616,6 +616,18 @@ summary,
|
||||
.stagger-enter > *:nth-child(9) { animation-delay: 320ms; }
|
||||
.stagger-enter > *:nth-child(n+10) { animation-delay: 360ms; }
|
||||
|
||||
/* Quiet settle for a freshly committed row (invoice editor line entry): a
|
||||
brief beige wash that fades to transparent, confirming where the row
|
||||
landed without moving anything. The global reduced-motion rule above
|
||||
collapses it to an instant no-op. */
|
||||
@keyframes row-settle {
|
||||
from { background-color: hsl(var(--secondary) / 0.7); }
|
||||
to { background-color: transparent; }
|
||||
}
|
||||
.row-settle {
|
||||
animation: row-settle 0.6s ease-out both;
|
||||
}
|
||||
|
||||
/* Row exit (booking/ignore/delete in dry-table lists). The page keeps the
|
||||
row rendered for a 350ms window (see finishBooking on /transactions) and
|
||||
marks it .row-exit; the row fades fast, then the space closes by
|
||||
|
||||
@@ -218,8 +218,11 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
|
||||
// Mobile: sit above the bottom nav (h-16 = 64px) AND the iOS home
|
||||
// indicator (env(safe-area-inset-bottom)). Still needed after the FAB
|
||||
// went desktop-only: the collapsed handle above renders on mobile too.
|
||||
// Desktop: standard 20px lift, no mobile nav to worry about.
|
||||
className={`fixed right-4 z-30 ${visibilityClass} h-12 max-w-[calc(100vw-2rem)] items-stretch rounded-full bg-foreground text-background shadow-lg bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4`}
|
||||
// Desktop: standard 20px lift, no mobile nav to worry about, except when
|
||||
// the page declares a bottom action bar (body[data-page-bottom-bar],
|
||||
// set by e.g. the standalone invoice editor): lift above it so the FAB
|
||||
// never covers the bar's primary button.
|
||||
className={`fixed right-4 z-30 ${visibilityClass} h-12 max-w-[calc(100vw-2rem)] items-stretch rounded-full bg-foreground text-background shadow-lg bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4 md:[body[data-page-bottom-bar]_&]:bottom-20`}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useState, useRef, useEffect, useMemo, useCallback, useId } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { foldText } from '@/lib/bookkeeping/account-search'
|
||||
|
||||
@@ -57,6 +57,11 @@ export default function ArticleCombobox({
|
||||
const [search, setSearch] = useState(selectedLabel)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||
// aria-activedescendant is only announced once the user has explicitly
|
||||
// arrowed into the list: before that, the visual highlight is a hint for
|
||||
// Enter behavior, not a selection a screen reader should read out.
|
||||
const [hasArrowNavigated, setHasArrowNavigated] = useState(false)
|
||||
const listboxId = useId()
|
||||
// Typing narrows the list; a fresh focus shows everything so the field also
|
||||
// works as a browse dropdown, exactly like the Select it replaces.
|
||||
const [hasTyped, setHasTyped] = useState(false)
|
||||
@@ -107,6 +112,7 @@ export default function ArticleCombobox({
|
||||
const currentKey = value ?? 'none'
|
||||
const idx = options.findIndex((o) => o.key === currentKey)
|
||||
setHighlightedIndex(idx >= 0 ? idx : 0)
|
||||
setHasArrowNavigated(false)
|
||||
setIsOpen(true)
|
||||
}, [options, value])
|
||||
|
||||
@@ -159,10 +165,12 @@ export default function ArticleCombobox({
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setHasArrowNavigated(true)
|
||||
setHighlightedIndex((prev) => Math.min(prev + 1, options.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setHasArrowNavigated(true)
|
||||
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
@@ -193,6 +201,7 @@ export default function ArticleCombobox({
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setHasTyped(true)
|
||||
setHasArrowNavigated(false)
|
||||
if (!isOpen) setIsOpen(true)
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
@@ -217,18 +226,31 @@ export default function ArticleCombobox({
|
||||
disabled={disabled}
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
isOpen && hasArrowNavigated && options[highlightedIndex]
|
||||
? `${listboxId}-opt-${highlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="absolute z-50 top-full left-0 mt-1 w-full min-w-[16rem] max-h-[300px] overflow-y-auto rounded-lg border border-input bg-card shadow-md"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<button
|
||||
key={option.key}
|
||||
id={`${listboxId}-opt-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.key === (value ?? 'none')}
|
||||
tabIndex={-1}
|
||||
data-highlighted={index === highlightedIndex}
|
||||
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer ${
|
||||
index === highlightedIndex ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
|
||||
|
||||
+1569
-1240
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,9 @@ interface InvoiceReviewContentProps {
|
||||
/** Mirrors `company_settings.vat_registered`. When false and the invoice carries
|
||||
* no VAT, the moms row is suppressed to match the PDF (pdf-template.tsx:876). */
|
||||
vatRegistered?: boolean
|
||||
/** Payment-link förval on this invoice: Stripe auto-link on send, a manually
|
||||
* pasted link, or none. Renders in the Förval summary line when set. */
|
||||
paymentLink?: 'auto' | 'manual' | null
|
||||
}
|
||||
|
||||
export function InvoiceReviewContent({
|
||||
@@ -64,6 +67,7 @@ export function InvoiceReviewContent({
|
||||
numberPreview,
|
||||
oreRounding,
|
||||
vatRegistered,
|
||||
paymentLink,
|
||||
}: InvoiceReviewContentProps) {
|
||||
const t = useTranslations('invoice_review')
|
||||
const rounding = getDisplayTotal({ total, currency }, { ore_rounding: oreRounding ?? true })
|
||||
@@ -118,6 +122,27 @@ export function InvoiceReviewContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applied förval: the collapsed defaults the invoice will carry
|
||||
(currency, öresavrundning, payment-link state). One muted line so
|
||||
the review states what the settings panel may have been hiding. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[
|
||||
t('forval_currency', { currency }),
|
||||
currency === 'SEK'
|
||||
? (oreRounding ?? true)
|
||||
? t('forval_ore_on')
|
||||
: t('forval_ore_off')
|
||||
: null,
|
||||
paymentLink === 'auto'
|
||||
? t('forval_link_auto')
|
||||
: paymentLink === 'manual'
|
||||
? t('forval_link_manual')
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
|
||||
{/* Line items: table on desktop, cards on mobile */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -139,7 +139,10 @@ export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null
|
||||
<Dialog open={open} onOpenChange={onOpenChange} modal={false}>
|
||||
<DialogVeil />
|
||||
<DialogContent
|
||||
className="sm:max-w-5xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// p-0/gap-0: the bare editor carries its own padding so its sticky
|
||||
// action bar can sit flush against the dialog's bottom edge (position
|
||||
// sticky binds to this DialogContent, the scroll container).
|
||||
className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto p-0 gap-0"
|
||||
// A half-typed invoice must survive an accidental backdrop click or a
|
||||
// stray Escape (nested comboboxes and date pickers portal outside the
|
||||
// dialog). Closing is explicit: the header X. Same convention as
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
deriveNextStep,
|
||||
deriveForvalChips,
|
||||
deriveRequiresHousing,
|
||||
filterArticleSuggestions,
|
||||
type NextStepInput,
|
||||
type ForvalChipsInput,
|
||||
} from '@/components/invoices/invoice-editor-flow'
|
||||
|
||||
function stepInput(overrides: Partial<NextStepInput> = {}): NextStepInput {
|
||||
return {
|
||||
isSelfBilled: false,
|
||||
customerSelected: true,
|
||||
invoiceDate: '2026-08-17',
|
||||
dueDate: '2026-09-16',
|
||||
receivedDate: '',
|
||||
externalInvoiceNumber: '',
|
||||
items: [
|
||||
{ line_type: 'product', description: 'Konsulttid', quantity: 10, unit: 'tim', unit_price: 1200 },
|
||||
],
|
||||
paymentLinkInvalid: false,
|
||||
requiresPersonnummer: false,
|
||||
personnummer: '',
|
||||
requiresHousing: false,
|
||||
housingDesignation: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('deriveNextStep priority order', () => {
|
||||
it('is ready for a complete invoice', () => {
|
||||
expect(deriveNextStep(stepInput())).toEqual({ kind: 'ready' })
|
||||
})
|
||||
|
||||
it('customer comes first, before everything else', () => {
|
||||
expect(
|
||||
deriveNextStep(stepInput({ customerSelected: false, invoiceDate: '', items: [] })),
|
||||
).toEqual({ kind: 'customer' })
|
||||
})
|
||||
|
||||
it('dates come before rows', () => {
|
||||
expect(deriveNextStep(stepInput({ invoiceDate: '', items: [] }))).toEqual({
|
||||
kind: 'invoice_date',
|
||||
})
|
||||
expect(deriveNextStep(stepInput({ dueDate: '', items: [] }))).toEqual({ kind: 'due_date' })
|
||||
})
|
||||
|
||||
it('asks for a first row when only text rows exist', () => {
|
||||
expect(
|
||||
deriveNextStep(stepInput({ items: [{ line_type: 'text', description: 'Enligt offert' }] })),
|
||||
).toEqual({ kind: 'rows_empty' })
|
||||
})
|
||||
|
||||
it('flags the first incomplete row, field by field, skipping text rows', () => {
|
||||
const items: NextStepInput['items'] = [
|
||||
{ line_type: 'text', description: '' },
|
||||
{ line_type: 'product', description: 'Ok', quantity: 1, unit: 'st', unit_price: 100 },
|
||||
{ line_type: 'product', description: '', quantity: 1, unit: 'st', unit_price: 0 },
|
||||
]
|
||||
expect(deriveNextStep(stepInput({ items }))).toEqual({
|
||||
kind: 'row_incomplete',
|
||||
index: 2,
|
||||
field: 'description',
|
||||
})
|
||||
items[2].description = 'Rad'
|
||||
items[2].quantity = 0
|
||||
expect(deriveNextStep(stepInput({ items }))).toEqual({
|
||||
kind: 'row_incomplete',
|
||||
index: 2,
|
||||
field: 'quantity',
|
||||
})
|
||||
items[2].quantity = NaN
|
||||
expect(deriveNextStep(stepInput({ items }))).toEqual({
|
||||
kind: 'row_incomplete',
|
||||
index: 2,
|
||||
field: 'quantity',
|
||||
})
|
||||
items[2].quantity = 2
|
||||
items[2].unit = ''
|
||||
expect(deriveNextStep(stepInput({ items }))).toEqual({
|
||||
kind: 'row_incomplete',
|
||||
index: 2,
|
||||
field: 'unit',
|
||||
})
|
||||
items[2].unit = 'st'
|
||||
items[2].unit_price = NaN
|
||||
expect(deriveNextStep(stepInput({ items }))).toEqual({
|
||||
kind: 'row_incomplete',
|
||||
index: 2,
|
||||
field: 'unit_price',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows negative and zero unit prices (discount lines)', () => {
|
||||
expect(
|
||||
deriveNextStep(
|
||||
stepInput({
|
||||
items: [
|
||||
{ line_type: 'product', description: 'Rabatt', quantity: 1, unit: 'st', unit_price: -100 },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual({ kind: 'ready' })
|
||||
})
|
||||
|
||||
it('routes to the payment link after rows', () => {
|
||||
expect(deriveNextStep(stepInput({ paymentLinkInvalid: true }))).toEqual({
|
||||
kind: 'payment_link',
|
||||
})
|
||||
})
|
||||
|
||||
it('asks for personnummer only when neither draft nor kundkort covers it', () => {
|
||||
expect(deriveNextStep(stepInput({ requiresPersonnummer: true }))).toEqual({
|
||||
kind: 'personnummer',
|
||||
})
|
||||
expect(
|
||||
deriveNextStep(stepInput({ requiresPersonnummer: true, personnummer: '19800101-1234' })),
|
||||
).toEqual({ kind: 'ready' })
|
||||
// Server fallback (stored last4 / kundkort) => requiresPersonnummer false.
|
||||
expect(deriveNextStep(stepInput({ requiresPersonnummer: false }))).toEqual({ kind: 'ready' })
|
||||
})
|
||||
|
||||
it('asks for fastighetsbeteckning when a ROT line exists', () => {
|
||||
expect(deriveNextStep(stepInput({ requiresHousing: true }))).toEqual({ kind: 'housing' })
|
||||
expect(
|
||||
deriveNextStep(stepInput({ requiresHousing: true, housingDesignation: 'Berga 2:11' })),
|
||||
).toEqual({ kind: 'ready' })
|
||||
})
|
||||
|
||||
it('skips the housing step for a ROT row whose amount is still zero (transient state)', () => {
|
||||
// The claim card only mounts while a deduction amount is claimed, so a
|
||||
// ROT-flagged row with price 0 must not produce a housing step: the
|
||||
// next-step link would try to focus an unmounted field.
|
||||
const requiresHousing = deriveRequiresHousing({ hasRotLine: true, deductionTotal: 0 })
|
||||
expect(requiresHousing).toBe(false)
|
||||
expect(deriveNextStep(stepInput({ requiresHousing }))).toEqual({ kind: 'ready' })
|
||||
})
|
||||
|
||||
it('requires housing once the ROT deduction carries an amount, but never for RUT alone', () => {
|
||||
expect(deriveRequiresHousing({ hasRotLine: true, deductionTotal: 360 })).toBe(true)
|
||||
expect(deriveRequiresHousing({ hasRotLine: false, deductionTotal: 500 })).toBe(false)
|
||||
})
|
||||
|
||||
it('self-billed extras come last: external number then received date', () => {
|
||||
expect(deriveNextStep(stepInput({ isSelfBilled: true }))).toEqual({ kind: 'external_number' })
|
||||
expect(
|
||||
deriveNextStep(stepInput({ isSelfBilled: true, externalInvoiceNumber: 'K-1' })),
|
||||
).toEqual({ kind: 'received_date' })
|
||||
expect(
|
||||
deriveNextStep(
|
||||
stepInput({ isSelfBilled: true, externalInvoiceNumber: 'K-1', receivedDate: '2026-08-15' }),
|
||||
),
|
||||
).toEqual({ kind: 'ready' })
|
||||
})
|
||||
})
|
||||
|
||||
function chipsInput(overrides: Partial<ForvalChipsInput> = {}): ForvalChipsInput {
|
||||
return {
|
||||
isSelfBilled: false,
|
||||
documentType: 'invoice',
|
||||
currency: 'SEK',
|
||||
invoiceDate: '2026-08-17',
|
||||
dueDate: '2026-09-16',
|
||||
receivedDate: '',
|
||||
deliveryDate: '',
|
||||
yourReference: '',
|
||||
paymentLink: null,
|
||||
oreRounding: true,
|
||||
dims: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('deriveForvalChips', () => {
|
||||
it('shows only currency and due terms for an all-default invoice', () => {
|
||||
expect(deriveForvalChips(chipsInput())).toEqual([
|
||||
{ kind: 'currency', currency: 'SEK' },
|
||||
{ kind: 'due_days', days: 30, date: '2026-09-16' },
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces a deviating document type first', () => {
|
||||
expect(deriveForvalChips(chipsInput({ documentType: 'proforma' }))[0]).toEqual({
|
||||
kind: 'doc_type',
|
||||
documentType: 'proforma',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a plain due date when the invoice date is missing or after', () => {
|
||||
expect(deriveForvalChips(chipsInput({ invoiceDate: '' }))).toContainEqual({
|
||||
kind: 'due_date',
|
||||
date: '2026-09-16',
|
||||
})
|
||||
expect(
|
||||
deriveForvalChips(chipsInput({ invoiceDate: '2026-10-01', dueDate: '2026-09-16' })),
|
||||
).toContainEqual({ kind: 'due_date', date: '2026-09-16' })
|
||||
})
|
||||
|
||||
it('surfaces every deviation an edit/copy draft may carry', () => {
|
||||
const chips = deriveForvalChips(
|
||||
chipsInput({
|
||||
documentType: 'proforma',
|
||||
currency: 'EUR',
|
||||
deliveryDate: '2026-08-20',
|
||||
yourReference: 'Anna',
|
||||
paymentLink: 'manual',
|
||||
oreRounding: false,
|
||||
dims: 'KS01 · P001',
|
||||
}),
|
||||
)
|
||||
expect(chips).toContainEqual({ kind: 'doc_type', documentType: 'proforma' })
|
||||
expect(chips).toContainEqual({ kind: 'currency', currency: 'EUR' })
|
||||
expect(chips).toContainEqual({ kind: 'delivery', date: '2026-08-20' })
|
||||
expect(chips).toContainEqual({ kind: 'your_reference', reference: 'Anna' })
|
||||
expect(chips).toContainEqual({ kind: 'payment_link', mode: 'manual' })
|
||||
expect(chips).toContainEqual({ kind: 'dims', dims: 'KS01 · P001' })
|
||||
// ore_off is SEK-only: an EUR invoice has no öresavrundning to disable.
|
||||
expect(chips.find((c) => c.kind === 'ore_off')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flags disabled öresavrundning on SEK invoices', () => {
|
||||
expect(deriveForvalChips(chipsInput({ oreRounding: false }))).toContainEqual({
|
||||
kind: 'ore_off',
|
||||
})
|
||||
})
|
||||
|
||||
it('reduces to currency, due and received for self-billed mode', () => {
|
||||
const chips = deriveForvalChips(
|
||||
chipsInput({
|
||||
isSelfBilled: true,
|
||||
receivedDate: '2026-08-15',
|
||||
documentType: 'proforma',
|
||||
yourReference: 'x',
|
||||
paymentLink: 'auto',
|
||||
oreRounding: false,
|
||||
dims: 'KS01',
|
||||
}),
|
||||
)
|
||||
expect(chips).toEqual([
|
||||
{ kind: 'currency', currency: 'SEK' },
|
||||
{ kind: 'due_days', days: 30, date: '2026-09-16' },
|
||||
{ kind: 'received', date: '2026-08-15' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterArticleSuggestions', () => {
|
||||
const articles = [
|
||||
{ id: 'a', article_number: '2', name: 'Skruvdragare' },
|
||||
{ id: 'b', article_number: '10', name: 'Städning, kontor' },
|
||||
{ id: 'c', article_number: null, name: 'Konsulttid' },
|
||||
]
|
||||
|
||||
it('browses everything on an empty query', () => {
|
||||
expect(filterArticleSuggestions(articles, '')).toHaveLength(3)
|
||||
expect(filterArticleSuggestions(articles, ' ')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('matches names diacritics-folded', () => {
|
||||
expect(filterArticleSuggestions(articles, 'stadning')).toEqual([articles[1]])
|
||||
expect(filterArticleSuggestions(articles, 'STÄD')).toEqual([articles[1]])
|
||||
})
|
||||
|
||||
it('matches article numbers', () => {
|
||||
expect(filterArticleSuggestions(articles, '10')).toEqual([articles[1]])
|
||||
})
|
||||
|
||||
it('returns nothing when nothing matches', () => {
|
||||
expect(filterArticleSuggestions(articles, 'zzz')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { differenceInCalendarDays, isValid, parseISO } from 'date-fns'
|
||||
import { foldText } from '@/lib/bookkeeping/account-search'
|
||||
|
||||
/**
|
||||
* Pure derivations behind the invoice editor's snabbflöde shell:
|
||||
*
|
||||
* - deriveNextStep: the single dynamic "Nästa steg" line (the page's only
|
||||
* ochre sentence) and the focus-routing target for an invalid submit.
|
||||
* - deriveForvalChips: the Förval chip line summarizing collapsed settings,
|
||||
* surfacing every value that deviates from its default so edit/copy mode
|
||||
* never round-trips values the user cannot see.
|
||||
* - filterArticleSuggestions: the unified row entry's autocomplete filter
|
||||
* (diacritics-folded, matches name and article number, same folding as
|
||||
* ArticleCombobox).
|
||||
*
|
||||
* Kept in a plain module (no JSX, no hooks) so the rules are unit-testable:
|
||||
* the repo does not render components in tests.
|
||||
*/
|
||||
|
||||
export interface NextStepItem {
|
||||
line_type?: 'product' | 'text' | null
|
||||
description?: string
|
||||
quantity?: number | null
|
||||
unit?: string
|
||||
unit_price?: number | null
|
||||
}
|
||||
|
||||
export type NextStepRowField = 'description' | 'quantity' | 'unit' | 'unit_price'
|
||||
|
||||
export type NextStep =
|
||||
| { kind: 'customer' }
|
||||
| { kind: 'invoice_date' }
|
||||
| { kind: 'due_date' }
|
||||
| { kind: 'rows_empty' }
|
||||
| { kind: 'row_incomplete'; index: number; field: NextStepRowField }
|
||||
| { kind: 'payment_link' }
|
||||
| { kind: 'personnummer' }
|
||||
| { kind: 'housing' }
|
||||
| { kind: 'external_number' }
|
||||
| { kind: 'received_date' }
|
||||
| { kind: 'ready' }
|
||||
|
||||
export interface NextStepInput {
|
||||
isSelfBilled: boolean
|
||||
customerSelected: boolean
|
||||
invoiceDate: string
|
||||
dueDate: string
|
||||
receivedDate: string
|
||||
externalInvoiceNumber: string
|
||||
items: NextStepItem[]
|
||||
/** True when the payment link field carries a validation error. */
|
||||
paymentLinkInvalid: boolean
|
||||
/** A deduction is claimed and neither draft last4 nor kundkort covers it. */
|
||||
requiresPersonnummer: boolean
|
||||
personnummer: string
|
||||
/**
|
||||
* A ROT line exists AND a deduction amount is claimed (fastighetsbeteckning
|
||||
* is then required). Derive via deriveRequiresHousing so the gate provably
|
||||
* matches the ROT/RUT claim card's mount condition.
|
||||
*/
|
||||
requiresHousing: boolean
|
||||
housingDesignation: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The housing (fastighetsbeteckning) requirement behind NextStepInput. A ROT
|
||||
* line alone is not enough: the claim card only mounts while a deduction
|
||||
* amount is claimed (deductionTotal > 0), so a ROT-flagged line whose amount
|
||||
* is still zero (transient state while typing) must not produce a housing
|
||||
* step, or the next-step link would try to focus an unmounted field.
|
||||
*/
|
||||
export function deriveRequiresHousing(input: {
|
||||
hasRotLine: boolean
|
||||
deductionTotal: number
|
||||
}): boolean {
|
||||
return input.hasRotLine && input.deductionTotal > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority order (the same order the invalid-submit focus routing walks):
|
||||
* customer -> dates -> first incomplete line -> payment link -> ROT/RUT claim
|
||||
* fields -> self-billed extras -> ready.
|
||||
*/
|
||||
export function deriveNextStep(input: NextStepInput): NextStep {
|
||||
if (!input.customerSelected) return { kind: 'customer' }
|
||||
if (!input.invoiceDate) return { kind: 'invoice_date' }
|
||||
if (!input.dueDate) return { kind: 'due_date' }
|
||||
|
||||
const productRows = input.items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter(({ item }) => item?.line_type !== 'text')
|
||||
if (productRows.length === 0) return { kind: 'rows_empty' }
|
||||
for (const { item, index } of productRows) {
|
||||
if (!item.description?.trim()) return { kind: 'row_incomplete', index, field: 'description' }
|
||||
// Mirrors the schema: quantity >= 0.01 (NaN fails the comparison too).
|
||||
if (!((item.quantity ?? 0) >= 0.01)) return { kind: 'row_incomplete', index, field: 'quantity' }
|
||||
if (!item.unit?.trim()) return { kind: 'row_incomplete', index, field: 'unit' }
|
||||
// Negative prices are lawful discount lines; only a non-number blocks.
|
||||
if (!Number.isFinite(item.unit_price ?? 0)) {
|
||||
return { kind: 'row_incomplete', index, field: 'unit_price' }
|
||||
}
|
||||
}
|
||||
|
||||
if (input.paymentLinkInvalid) return { kind: 'payment_link' }
|
||||
if (input.requiresPersonnummer && !input.personnummer.trim()) return { kind: 'personnummer' }
|
||||
if (input.requiresHousing && !input.housingDesignation.trim()) return { kind: 'housing' }
|
||||
|
||||
if (input.isSelfBilled) {
|
||||
if (!input.externalInvoiceNumber.trim()) return { kind: 'external_number' }
|
||||
if (!input.receivedDate) return { kind: 'received_date' }
|
||||
}
|
||||
return { kind: 'ready' }
|
||||
}
|
||||
|
||||
export type ForvalChip =
|
||||
| { kind: 'doc_type'; documentType: 'proforma' | 'delivery_note' }
|
||||
| { kind: 'currency'; currency: string }
|
||||
| { kind: 'due_days'; days: number; date: string }
|
||||
| { kind: 'due_date'; date: string }
|
||||
| { kind: 'received'; date: string }
|
||||
| { kind: 'delivery'; date: string }
|
||||
| { kind: 'your_reference'; reference: string }
|
||||
| { kind: 'payment_link'; mode: 'auto' | 'manual' }
|
||||
| { kind: 'ore_off' }
|
||||
| { kind: 'dims'; dims: string }
|
||||
|
||||
export interface ForvalChipsInput {
|
||||
isSelfBilled: boolean
|
||||
documentType: 'invoice' | 'proforma' | 'delivery_note'
|
||||
currency: string
|
||||
invoiceDate: string
|
||||
dueDate: string
|
||||
receivedDate: string
|
||||
deliveryDate: string
|
||||
yourReference: string
|
||||
paymentLink: 'auto' | 'manual' | null
|
||||
oreRounding: boolean
|
||||
/** Compact display of the invoice-level default dims, or null when none. */
|
||||
dims: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip line renders the always-relevant defaults (currency, due terms)
|
||||
* plus every collapsed setting whose value deviates from its default. A
|
||||
* deviating value MUST surface here: in edit/copy mode the draft may carry a
|
||||
* proforma type, an EUR currency, a payment link or dimension defaults that
|
||||
* would otherwise round-trip invisibly through PATCH.
|
||||
*/
|
||||
export function deriveForvalChips(input: ForvalChipsInput): ForvalChip[] {
|
||||
const chips: ForvalChip[] = []
|
||||
if (!input.isSelfBilled && input.documentType !== 'invoice') {
|
||||
chips.push({ kind: 'doc_type', documentType: input.documentType })
|
||||
}
|
||||
chips.push({ kind: 'currency', currency: input.currency })
|
||||
if (input.dueDate) {
|
||||
const days = dueDays(input.invoiceDate, input.dueDate)
|
||||
if (days !== null && days >= 0) chips.push({ kind: 'due_days', days, date: input.dueDate })
|
||||
else chips.push({ kind: 'due_date', date: input.dueDate })
|
||||
}
|
||||
if (input.isSelfBilled && input.receivedDate) {
|
||||
chips.push({ kind: 'received', date: input.receivedDate })
|
||||
}
|
||||
if (!input.isSelfBilled && input.deliveryDate) {
|
||||
chips.push({ kind: 'delivery', date: input.deliveryDate })
|
||||
}
|
||||
if (!input.isSelfBilled && input.yourReference.trim()) {
|
||||
chips.push({ kind: 'your_reference', reference: input.yourReference.trim() })
|
||||
}
|
||||
if (!input.isSelfBilled && input.paymentLink) {
|
||||
chips.push({ kind: 'payment_link', mode: input.paymentLink })
|
||||
}
|
||||
if (!input.isSelfBilled && !input.oreRounding && input.currency === 'SEK') {
|
||||
chips.push({ kind: 'ore_off' })
|
||||
}
|
||||
if (!input.isSelfBilled && input.dims) {
|
||||
chips.push({ kind: 'dims', dims: input.dims })
|
||||
}
|
||||
return chips
|
||||
}
|
||||
|
||||
function dueDays(invoiceDate: string, dueDate: string): number | null {
|
||||
if (!invoiceDate || !dueDate) return null
|
||||
const from = parseISO(invoiceDate)
|
||||
const to = parseISO(dueDate)
|
||||
if (!isValid(from) || !isValid(to)) return null
|
||||
return differenceInCalendarDays(to, from)
|
||||
}
|
||||
|
||||
export interface ArticleSuggestion {
|
||||
id: string
|
||||
article_number: string | null
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter for the unified row entry: empty query browses everything, a query
|
||||
* matches name and article number, diacritics-folded (same folding as
|
||||
* ArticleCombobox so the two article surfaces agree on what matches).
|
||||
*/
|
||||
export function filterArticleSuggestions<T extends ArticleSuggestion>(
|
||||
articles: T[],
|
||||
query: string,
|
||||
): T[] {
|
||||
const q = foldText(query.trim())
|
||||
if (!q) return articles
|
||||
return articles.filter((a) => foldText(`${a.article_number ?? ''} ${a.name}`).includes(q))
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildInvoiceWritePayload,
|
||||
buildSelfBilledPayload,
|
||||
hasDimensionValues,
|
||||
pruneItemDimensions,
|
||||
sanitizeDeductionItems,
|
||||
stripSelfBillingFields,
|
||||
} from '@/lib/invoices/editor-payload'
|
||||
|
||||
/**
|
||||
* Payload-parity ratchet for the invoice editor rebuild.
|
||||
*
|
||||
* The "legacy" builders below are verbatim re-implementations of the three
|
||||
* inline payload builders that lived in InvoiceEditor.tsx (handleConfirm,
|
||||
* saveDraftData, saveEdit) and the inline self-billed body mapper, before the
|
||||
* extraction into lib/invoices/editor-payload.ts. Every combination in the
|
||||
* matrix asserts JSON.stringify equality between the extracted builder and
|
||||
* the legacy recipe: what goes over the wire may not change by a byte.
|
||||
*/
|
||||
|
||||
type Item = {
|
||||
line_type?: 'product' | 'text'
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
vat_rate: number
|
||||
article_id?: string | null
|
||||
revenue_account?: string | null
|
||||
deduction_type?: 'rot' | 'rut' | null
|
||||
labor_hours?: number | null
|
||||
work_type?: string | null
|
||||
housing_designation?: string | null
|
||||
apartment_number?: string | null
|
||||
brf_org_number?: string | null
|
||||
accrual_period_start?: string | null
|
||||
accrual_period_end?: string | null
|
||||
accrual_balance_account?: string | null
|
||||
dimensions?: Record<string, string> | null
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
customer_id: string
|
||||
invoice_date: string
|
||||
due_date: string
|
||||
delivery_date?: string
|
||||
currency: string
|
||||
document_type: 'invoice' | 'proforma' | 'delivery_note'
|
||||
your_reference?: string
|
||||
our_reference?: string
|
||||
notes?: string
|
||||
payment_link_url?: string
|
||||
payment_link_auto?: boolean
|
||||
external_invoice_number?: string
|
||||
self_billing_agreement_ref?: string
|
||||
received_date?: string
|
||||
deduction_personnummer?: string
|
||||
deduction_housing_designation?: string
|
||||
items: Item[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy recipes (verbatim from InvoiceEditor.tsx before the extraction)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function legacyHasDimensionValues(dims: Record<string, string> | null | undefined): boolean {
|
||||
return !!dims && Object.keys(dims).length > 0
|
||||
}
|
||||
|
||||
function legacyPruneItemDimensions<T extends { dimensions?: Record<string, string> | null }>(
|
||||
items: T[],
|
||||
): T[] {
|
||||
return items.map((item) =>
|
||||
legacyHasDimensionValues(item.dimensions) ? item : { ...item, dimensions: undefined },
|
||||
)
|
||||
}
|
||||
|
||||
function legacyStripSelfBillingFields(data: FormData): Omit<
|
||||
FormData,
|
||||
'external_invoice_number' | 'self_billing_agreement_ref' | 'received_date'
|
||||
> {
|
||||
const {
|
||||
external_invoice_number: _ein,
|
||||
self_billing_agreement_ref: _sbar,
|
||||
received_date: _rd,
|
||||
...rest
|
||||
} = data
|
||||
return rest
|
||||
}
|
||||
|
||||
function legacySanitizedItems(items: Item[]) {
|
||||
return legacyPruneItemDimensions(items).map((item) => {
|
||||
if (item.deduction_type) return item
|
||||
const {
|
||||
deduction_type: _dt,
|
||||
labor_hours: _lh,
|
||||
work_type: _wt,
|
||||
housing_designation: _hd,
|
||||
apartment_number: _an,
|
||||
brf_org_number: _bn,
|
||||
...rest
|
||||
} = item
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
/** handleConfirm's inline body (create with review). */
|
||||
function legacyCreatePayload(
|
||||
data: FormData,
|
||||
oreRounding: boolean,
|
||||
defaultDims: Record<string, string>,
|
||||
) {
|
||||
const anyDeduction = data.items.some((i) => i.deduction_type)
|
||||
return {
|
||||
...legacyStripSelfBillingFields(data),
|
||||
ore_rounding: oreRounding,
|
||||
default_dimensions: defaultDims,
|
||||
items: legacySanitizedItems(data.items),
|
||||
...(anyDeduction
|
||||
? {}
|
||||
: { deduction_personnummer: undefined, deduction_housing_designation: undefined }),
|
||||
}
|
||||
}
|
||||
|
||||
/** saveDraftData's inline body (create + save_as_draft). */
|
||||
function legacyDraftPayload(
|
||||
data: FormData,
|
||||
oreRounding: boolean,
|
||||
defaultDims: Record<string, string>,
|
||||
) {
|
||||
const anyDeduction = data.items.some((i) => i.deduction_type)
|
||||
return {
|
||||
...legacyStripSelfBillingFields(data),
|
||||
save_as_draft: true,
|
||||
ore_rounding: oreRounding,
|
||||
default_dimensions: defaultDims,
|
||||
items: legacySanitizedItems(data.items),
|
||||
...(anyDeduction
|
||||
? {}
|
||||
: { deduction_personnummer: undefined, deduction_housing_designation: undefined }),
|
||||
}
|
||||
}
|
||||
|
||||
/** saveEdit's inline body (PATCH). Identical recipe to create. */
|
||||
const legacyEditPayload = legacyCreatePayload
|
||||
|
||||
/** handleSelfBilledSubmit's inline body. */
|
||||
function legacySelfBilledPayload(data: FormData) {
|
||||
return {
|
||||
customer_id: data.customer_id,
|
||||
external_invoice_number: data.external_invoice_number,
|
||||
self_billing_agreement_ref: data.self_billing_agreement_ref || undefined,
|
||||
invoice_date: data.invoice_date,
|
||||
received_date: data.received_date,
|
||||
due_date: data.due_date,
|
||||
currency: data.currency,
|
||||
notes: data.notes,
|
||||
items: data.items.map((i) => ({
|
||||
description: i.description,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
unit_price: i.unit_price,
|
||||
vat_rate: i.vat_rate,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function plainItem(overrides: Partial<Item> = {}): Item {
|
||||
return {
|
||||
line_type: 'product',
|
||||
description: 'Konsulttid',
|
||||
quantity: 10,
|
||||
unit: 'tim',
|
||||
unit_price: 1200,
|
||||
vat_rate: 25,
|
||||
article_id: null,
|
||||
revenue_account: null,
|
||||
deduction_type: null,
|
||||
labor_hours: null,
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
dimensions: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function rotItem(overrides: Partial<Item> = {}): Item {
|
||||
return plainItem({
|
||||
description: 'Målning av fasad',
|
||||
deduction_type: 'rot',
|
||||
labor_hours: 12,
|
||||
work_type: 'malning',
|
||||
housing_designation: 'Stockholm Vasastan 1:23',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function textItem(overrides: Partial<Item> = {}): Item {
|
||||
return plainItem({
|
||||
line_type: 'text',
|
||||
description: 'Enligt offert 2026-14',
|
||||
quantity: 0,
|
||||
unit: '',
|
||||
unit_price: 0,
|
||||
vat_rate: 0,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function form(overrides: Partial<FormData> = {}): FormData {
|
||||
return {
|
||||
customer_id: 'cust-1',
|
||||
invoice_date: '2026-08-17',
|
||||
due_date: '2026-09-16',
|
||||
delivery_date: '',
|
||||
currency: 'SEK',
|
||||
document_type: 'invoice',
|
||||
your_reference: 'Anna',
|
||||
our_reference: 'Jakob',
|
||||
notes: 'Tack för samarbetet',
|
||||
payment_link_url: '',
|
||||
payment_link_auto: true,
|
||||
external_invoice_number: '',
|
||||
self_billing_agreement_ref: '',
|
||||
received_date: '',
|
||||
deduction_personnummer: '',
|
||||
deduction_housing_designation: '',
|
||||
items: [plainItem()],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const jsonOf = (v: unknown) => JSON.stringify(v)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parity matrix: create / draft / edit x deduction x dimensions x text rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildInvoiceWritePayload parity with the legacy inline builders', () => {
|
||||
const itemVariants: Array<[string, Item[]]> = [
|
||||
['plain single line', [plainItem()]],
|
||||
['ROT deduction line', [rotItem()]],
|
||||
['mixed rot + plain + text', [rotItem(), plainItem(), textItem()]],
|
||||
[
|
||||
'dimensions: empty bag pruned, non-empty kept',
|
||||
[
|
||||
plainItem({ dimensions: {} }),
|
||||
plainItem({ dimensions: { '1': 'KS01', '6': 'P001' } }),
|
||||
],
|
||||
],
|
||||
[
|
||||
'accrual line without deduction keeps accrual fields',
|
||||
[
|
||||
plainItem({
|
||||
accrual_period_start: '2026-09-01',
|
||||
accrual_period_end: '2026-12-31',
|
||||
accrual_balance_account: '2990',
|
||||
}),
|
||||
],
|
||||
],
|
||||
[
|
||||
'RUT with per-item override account and dims',
|
||||
[
|
||||
rotItem({
|
||||
deduction_type: 'rut',
|
||||
work_type: 'stadning',
|
||||
revenue_account: '3041',
|
||||
dimensions: { '6': 'P002' },
|
||||
}),
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
const formVariants: Array<[string, Partial<FormData>]> = [
|
||||
['default create form', {}],
|
||||
[
|
||||
'claim fields filled',
|
||||
{ deduction_personnummer: '19800101-1234', deduction_housing_designation: 'Berga 2:11' },
|
||||
],
|
||||
[
|
||||
'self-billing carriers accidentally non-empty are stripped',
|
||||
{ external_invoice_number: 'K-99', self_billing_agreement_ref: 'AVT-1', received_date: '2026-08-01' },
|
||||
],
|
||||
['EUR proforma with payment link', { currency: 'EUR', document_type: 'proforma', payment_link_url: 'https://buy.stripe.com/x' }],
|
||||
]
|
||||
|
||||
for (const [itemLabel, items] of itemVariants) {
|
||||
for (const [formLabel, formOverrides] of formVariants) {
|
||||
const data = form({ ...formOverrides, items })
|
||||
const dimsVariants: Array<Record<string, string>> = [{}, { '1': 'KS01' }]
|
||||
for (const oreRounding of [true, false]) {
|
||||
for (const defaultDims of dimsVariants) {
|
||||
it(`create: ${itemLabel} / ${formLabel} / ore=${oreRounding} / dims=${jsonOf(defaultDims)}`, () => {
|
||||
expect(
|
||||
jsonOf(buildInvoiceWritePayload(data, { oreRounding, defaultDims })),
|
||||
).toBe(jsonOf(legacyCreatePayload(data, oreRounding, defaultDims)))
|
||||
})
|
||||
it(`draft: ${itemLabel} / ${formLabel} / ore=${oreRounding} / dims=${jsonOf(defaultDims)}`, () => {
|
||||
expect(
|
||||
jsonOf(
|
||||
buildInvoiceWritePayload(data, { saveAsDraft: true, oreRounding, defaultDims }),
|
||||
),
|
||||
).toBe(jsonOf(legacyDraftPayload(data, oreRounding, defaultDims)))
|
||||
})
|
||||
it(`edit: ${itemLabel} / ${formLabel} / ore=${oreRounding} / dims=${jsonOf(defaultDims)}`, () => {
|
||||
expect(
|
||||
jsonOf(buildInvoiceWritePayload(data, { oreRounding, defaultDims })),
|
||||
).toBe(jsonOf(legacyEditPayload(data, oreRounding, defaultDims)))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('buildInvoiceWritePayload semantics', () => {
|
||||
it('drops the self-billing carriers from the wire body', () => {
|
||||
const body = JSON.parse(
|
||||
jsonOf(
|
||||
buildInvoiceWritePayload(
|
||||
form({ external_invoice_number: 'X', self_billing_agreement_ref: 'Y', received_date: '2026-01-01' }),
|
||||
{ oreRounding: true, defaultDims: {} },
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(body).not.toHaveProperty('external_invoice_number')
|
||||
expect(body).not.toHaveProperty('self_billing_agreement_ref')
|
||||
expect(body).not.toHaveProperty('received_date')
|
||||
})
|
||||
|
||||
it('omits save_as_draft entirely unless requested', () => {
|
||||
const noDraft = buildInvoiceWritePayload(form(), { oreRounding: true, defaultDims: {} })
|
||||
expect('save_as_draft' in noDraft).toBe(false)
|
||||
const draft = buildInvoiceWritePayload(form(), {
|
||||
saveAsDraft: true,
|
||||
oreRounding: true,
|
||||
defaultDims: {},
|
||||
})
|
||||
expect(draft.save_as_draft).toBe(true)
|
||||
})
|
||||
|
||||
it('strips invoice-level personnummer/housing when no line claims a deduction', () => {
|
||||
const body = JSON.parse(
|
||||
jsonOf(
|
||||
buildInvoiceWritePayload(
|
||||
form({
|
||||
deduction_personnummer: '19800101-1234',
|
||||
deduction_housing_designation: 'Berga 2:11',
|
||||
items: [plainItem()],
|
||||
}),
|
||||
{ oreRounding: true, defaultDims: {} },
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(body).not.toHaveProperty('deduction_personnummer')
|
||||
expect(body).not.toHaveProperty('deduction_housing_designation')
|
||||
})
|
||||
|
||||
it('keeps invoice-level personnummer/housing when any line claims a deduction', () => {
|
||||
const body = JSON.parse(
|
||||
jsonOf(
|
||||
buildInvoiceWritePayload(
|
||||
form({
|
||||
deduction_personnummer: '19800101-1234',
|
||||
deduction_housing_designation: 'Berga 2:11',
|
||||
items: [rotItem(), plainItem()],
|
||||
}),
|
||||
{ oreRounding: true, defaultDims: {} },
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(body.deduction_personnummer).toBe('19800101-1234')
|
||||
expect(body.deduction_housing_designation).toBe('Berga 2:11')
|
||||
})
|
||||
|
||||
it('privacy-strips the six ROT/RUT fields only from non-deduction lines', () => {
|
||||
const body = JSON.parse(
|
||||
jsonOf(
|
||||
buildInvoiceWritePayload(form({ items: [rotItem(), plainItem()] }), {
|
||||
oreRounding: false,
|
||||
defaultDims: {},
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(body.items[0].deduction_type).toBe('rot')
|
||||
expect(body.items[0].labor_hours).toBe(12)
|
||||
expect(body.items[0].work_type).toBe('malning')
|
||||
for (const key of [
|
||||
'deduction_type',
|
||||
'labor_hours',
|
||||
'work_type',
|
||||
'housing_designation',
|
||||
'apartment_number',
|
||||
'brf_org_number',
|
||||
]) {
|
||||
expect(body.items[1]).not.toHaveProperty(key)
|
||||
}
|
||||
// Non-personal fields survive the strip.
|
||||
expect(body.items[1].description).toBe('Konsulttid')
|
||||
expect(body.items[1].accrual_period_start).toBeNull()
|
||||
})
|
||||
|
||||
it('always sends ore_rounding and default_dimensions ({} clears)', () => {
|
||||
const body = JSON.parse(
|
||||
jsonOf(buildInvoiceWritePayload(form(), { oreRounding: false, defaultDims: {} })),
|
||||
)
|
||||
expect(body.ore_rounding).toBe(false)
|
||||
expect(body.default_dimensions).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pruneItemDimensions', () => {
|
||||
it('turns empty and null bags into undefined (inherit) and keeps valued bags', () => {
|
||||
const [a, b, c] = pruneItemDimensions([
|
||||
plainItem({ dimensions: {} }),
|
||||
plainItem({ dimensions: null }),
|
||||
plainItem({ dimensions: { '1': 'KS01' } }),
|
||||
])
|
||||
expect(a.dimensions).toBeUndefined()
|
||||
expect(b.dimensions).toBeUndefined()
|
||||
expect(c.dimensions).toEqual({ '1': 'KS01' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasDimensionValues', () => {
|
||||
it('is false for null/undefined/empty and true for a valued bag', () => {
|
||||
expect(hasDimensionValues(null)).toBe(false)
|
||||
expect(hasDimensionValues(undefined)).toBe(false)
|
||||
expect(hasDimensionValues({})).toBe(false)
|
||||
expect(hasDimensionValues({ '6': 'P001' })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripSelfBillingFields / sanitizeDeductionItems', () => {
|
||||
it('stripSelfBillingFields removes exactly the three carriers', () => {
|
||||
const out = stripSelfBillingFields(form()) as Record<string, unknown>
|
||||
expect(out).not.toHaveProperty('external_invoice_number')
|
||||
expect(out).not.toHaveProperty('self_billing_agreement_ref')
|
||||
expect(out).not.toHaveProperty('received_date')
|
||||
expect(out.customer_id).toBe('cust-1')
|
||||
expect(out.items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sanitizeDeductionItems leaves deduction lines untouched (same reference)', () => {
|
||||
const rot = rotItem()
|
||||
const out = sanitizeDeductionItems([rot])
|
||||
expect(out[0]).toBe(rot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSelfBilledPayload parity and semantics', () => {
|
||||
const selfBilledForm = form({
|
||||
external_invoice_number: 'K-2026-17',
|
||||
self_billing_agreement_ref: '',
|
||||
received_date: '2026-08-15',
|
||||
notes: 'Mottagen självfaktura',
|
||||
items: [
|
||||
plainItem({ article_id: 'art-1', revenue_account: '3001', dimensions: { '1': 'KS01' } }),
|
||||
plainItem({ description: 'Frakt', quantity: 1, unit: 'st', unit_price: 120, vat_rate: 25 }),
|
||||
],
|
||||
})
|
||||
|
||||
it('matches the legacy inline body byte for byte', () => {
|
||||
expect(jsonOf(buildSelfBilledPayload(selfBilledForm))).toBe(
|
||||
jsonOf(legacySelfBilledPayload(selfBilledForm)),
|
||||
)
|
||||
})
|
||||
|
||||
it('reduces items to the five wire fields', () => {
|
||||
const body = JSON.parse(jsonOf(buildSelfBilledPayload(selfBilledForm)))
|
||||
expect(Object.keys(body.items[0]).sort()).toEqual([
|
||||
'description',
|
||||
'quantity',
|
||||
'unit',
|
||||
'unit_price',
|
||||
'vat_rate',
|
||||
])
|
||||
})
|
||||
|
||||
it("coerces an empty agreement ref to undefined so it drops off the wire", () => {
|
||||
const body = JSON.parse(jsonOf(buildSelfBilledPayload(selfBilledForm)))
|
||||
expect(body).not.toHaveProperty('self_billing_agreement_ref')
|
||||
const withRef = JSON.parse(
|
||||
jsonOf(buildSelfBilledPayload({ ...selfBilledForm, self_billing_agreement_ref: 'AVT-9' })),
|
||||
)
|
||||
expect(withRef.self_billing_agreement_ref).toBe('AVT-9')
|
||||
})
|
||||
|
||||
it('never sends document_type, ROT/RUT or payment-link fields', () => {
|
||||
const body = JSON.parse(jsonOf(buildSelfBilledPayload(selfBilledForm)))
|
||||
for (const key of ['document_type', 'payment_link_url', 'payment_link_auto', 'ore_rounding', 'deduction_personnummer']) {
|
||||
expect(body).not.toHaveProperty(key)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Pure payload builders for the invoice editor (components/invoices/
|
||||
* InvoiceEditor.tsx). Extracted so the exact request bodies the editor sends
|
||||
* to POST /api/invoices, PATCH /api/invoices/[id] and POST
|
||||
* /api/invoices/self-billed are pinned by unit tests: the repo renders no
|
||||
* components in tests, so these builders are the byte-compatibility ratchet
|
||||
* under any editor re-layout.
|
||||
*
|
||||
* Nothing in here may read component state: every input arrives as an
|
||||
* argument, every function is a pure mapping from form data to wire body.
|
||||
*/
|
||||
|
||||
/** True when a dimensions bag ({sie_dim_no: code}) carries at least one value. */
|
||||
export function hasDimensionValues(
|
||||
dims: Record<string, string> | null | undefined,
|
||||
): boolean {
|
||||
return !!dims && Object.keys(dims).length > 0
|
||||
}
|
||||
|
||||
/** The self-billing carrier fields the form always holds (default ''). */
|
||||
export interface SelfBillingCarrierFields {
|
||||
external_invoice_number?: string
|
||||
self_billing_agreement_ref?: string
|
||||
received_date?: string
|
||||
}
|
||||
|
||||
/** The per-item ROT/RUT fields that are privacy-stripped when unused. */
|
||||
export interface DeductionItemFields {
|
||||
deduction_type?: 'rot' | 'rut' | null
|
||||
labor_hours?: number | null
|
||||
work_type?: string | null
|
||||
housing_designation?: string | null
|
||||
apartment_number?: string | null
|
||||
brf_org_number?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-item bags ride the payload only when they carry values: the server
|
||||
* treats an absent bag as "inherit the invoice's default_dimensions".
|
||||
*/
|
||||
export function pruneItemDimensions<
|
||||
T extends { dimensions?: Record<string, string> | null },
|
||||
>(items: T[]): T[] {
|
||||
return items.map((item) =>
|
||||
hasDimensionValues(item.dimensions) ? item : { ...item, dimensions: undefined },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The form always carries the self-billing fields (they default to '' in both
|
||||
* create and edit mode). The editor's normal create/draft/edit flows never
|
||||
* use self-billing (that goes through /api/invoices/self-billed), so drop the
|
||||
* empty carriers before spreading the form data into the /api/invoices (or
|
||||
* PATCH) body: a bare external_invoice_number: '' otherwise trips the shared
|
||||
* CreateInvoiceSchema's min(1). Belt-and-suspenders; the server schema also
|
||||
* coerces '' to undefined for these fields.
|
||||
*/
|
||||
export function stripSelfBillingFields<T extends SelfBillingCarrierFields>(
|
||||
data: T,
|
||||
): Omit<T, keyof SelfBillingCarrierFields> {
|
||||
const {
|
||||
external_invoice_number: _ein,
|
||||
self_billing_agreement_ref: _sbar,
|
||||
received_date: _rd,
|
||||
...rest
|
||||
} = data
|
||||
return rest
|
||||
}
|
||||
|
||||
/**
|
||||
* Privacy by default: ROT/RUT line fields are only sent to the API when the
|
||||
* line actually claims a deduction. Defaults are pre-instantiated as null in
|
||||
* the form state, but null personal-data fields shouldn't ride along on every
|
||||
* regular invoice.
|
||||
*/
|
||||
export function sanitizeDeductionItems<T extends DeductionItemFields>(
|
||||
items: T[],
|
||||
): Array<T | Omit<T, keyof DeductionItemFields>> {
|
||||
return items.map((item) => {
|
||||
if (item.deduction_type) return item
|
||||
const {
|
||||
deduction_type: _dt,
|
||||
labor_hours: _lh,
|
||||
work_type: _wt,
|
||||
housing_designation: _hd,
|
||||
apartment_number: _an,
|
||||
brf_org_number: _bn,
|
||||
...rest
|
||||
} = item
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
export interface InvoiceWritePayloadOptions {
|
||||
/** POST with save_as_draft: true (unnumbered draft, no invoice.created). */
|
||||
saveAsDraft?: boolean
|
||||
/** Öresavrundning display flag (component state, not a form field). */
|
||||
oreRounding: boolean
|
||||
/** Invoice-level default dims: always sent so an edit can clear them ({} = none). */
|
||||
defaultDims: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* The one body builder behind "Granska & skapa" (POST), "Spara som utkast"
|
||||
* (POST + save_as_draft) and edit mode (PATCH). Unifies the three previously
|
||||
* inline, near-identical builders in InvoiceEditor.tsx: same dimension
|
||||
* pruning, same ROT/RUT privacy strip, same invoice-level personnummer /
|
||||
* housing sanitization.
|
||||
*/
|
||||
export function buildInvoiceWritePayload<
|
||||
TItem extends DeductionItemFields & { dimensions?: Record<string, string> | null },
|
||||
TForm extends SelfBillingCarrierFields & { items: TItem[] },
|
||||
>(data: TForm, options: InvoiceWritePayloadOptions) {
|
||||
const anyDeduction = data.items.some((i) => i.deduction_type)
|
||||
const sanitizedItems = sanitizeDeductionItems(pruneItemDimensions(data.items))
|
||||
return {
|
||||
...stripSelfBillingFields(data),
|
||||
...(options.saveAsDraft ? { save_as_draft: true } : {}),
|
||||
ore_rounding: options.oreRounding,
|
||||
default_dimensions: options.defaultDims,
|
||||
items: sanitizedItems,
|
||||
// Invoice-level personnummer/housing only ride along when a deduction is
|
||||
// actually claimed somewhere. undefined keys disappear in JSON.
|
||||
...(anyDeduction
|
||||
? {}
|
||||
: { deduction_personnummer: undefined, deduction_housing_designation: undefined }),
|
||||
}
|
||||
}
|
||||
|
||||
/** The reduced field set POST /api/invoices/self-billed accepts. */
|
||||
export interface SelfBilledFormShape {
|
||||
customer_id: string
|
||||
external_invoice_number?: string
|
||||
self_billing_agreement_ref?: string
|
||||
invoice_date: string
|
||||
received_date?: string
|
||||
due_date: string
|
||||
currency: string
|
||||
notes?: string
|
||||
items: Array<{
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
vat_rate: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Body mapper for a mottagen självfaktura (POST /api/invoices/self-billed):
|
||||
* a faithful revenue-only transcription, so items are reduced to the five
|
||||
* wire fields and none of the invoice-doc extras (document_type, ROT/RUT,
|
||||
* payment link, ore_rounding) are sent.
|
||||
*/
|
||||
export function buildSelfBilledPayload<T extends SelfBilledFormShape>(data: T) {
|
||||
return {
|
||||
customer_id: data.customer_id,
|
||||
external_invoice_number: data.external_invoice_number,
|
||||
self_billing_agreement_ref: data.self_billing_agreement_ref || undefined,
|
||||
invoice_date: data.invoice_date,
|
||||
received_date: data.received_date,
|
||||
due_date: data.due_date,
|
||||
currency: data.currency,
|
||||
notes: data.notes,
|
||||
items: data.items.map((i) => ({
|
||||
description: i.description,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
unit_price: i.unit_price,
|
||||
vat_rate: i.vat_rate,
|
||||
})),
|
||||
}
|
||||
}
|
||||
+42
-15
@@ -3420,6 +3420,43 @@
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"invoice_editor": {
|
||||
"amount_label": "Amount",
|
||||
"rows_count": "{count, plural, =1 {1 line} other {# lines}}",
|
||||
"customer_done": "selected",
|
||||
"optional_label": "(optional)",
|
||||
"section_forval": "Defaults",
|
||||
"forval_edit": "Change defaults",
|
||||
"entry_placeholder": "Type freely or search articles ...",
|
||||
"entry_aria": "New invoice line",
|
||||
"entry_hint_matches": "Arrow down picks an article, Enter adds it as free text",
|
||||
"entry_hint_free": "Enter adds the line as free text",
|
||||
"row_menu_pick_article": "Choose article",
|
||||
"remove_row_aria_named": "Remove line: {description}",
|
||||
"validation_price_invalid": "Enter a unit price",
|
||||
"chip_currency": "Currency {currency}",
|
||||
"chip_due_days": "Due in {days} days ({date})",
|
||||
"chip_due_date": "Due {date}",
|
||||
"chip_received": "Received {date}",
|
||||
"chip_delivery": "Delivery date {date}",
|
||||
"chip_your_reference": "Your reference: {reference}",
|
||||
"chip_stripe_auto": "Stripe link created on send",
|
||||
"chip_payment_link": "Payment link added",
|
||||
"chip_ore_off": "No öre rounding",
|
||||
"chip_dims": "Dimensions: {dims}",
|
||||
"next_step_prefix": "Next step:",
|
||||
"next_step_customer": "choose a customer",
|
||||
"next_step_invoice_date": "set the invoice date",
|
||||
"next_step_due_date": "set the due date",
|
||||
"next_step_add_row": "add an invoice line",
|
||||
"next_step_row_incomplete": "complete line {index}",
|
||||
"next_step_payment_link": "check the payment link",
|
||||
"next_step_personnummer": "enter the personal number for the deduction",
|
||||
"next_step_housing": "enter the property designation",
|
||||
"next_step_external_number": "enter the customer's invoice number",
|
||||
"next_step_received_date": "set the received date",
|
||||
"ready_create": "Ready to review: everything else has sensible defaults.",
|
||||
"ready_edit": "Ready to save.",
|
||||
"ready_self_billed": "Ready to register.",
|
||||
"row_menu_set_account": "Set posting account",
|
||||
"row_menu_remove_account": "Remove posting account",
|
||||
"row_menu_set_dimensions": "Set cost centre/project",
|
||||
@@ -3442,10 +3479,6 @@
|
||||
"title_proforma": "New proforma invoice",
|
||||
"title_delivery_note": "New delivery note",
|
||||
"title_copy": "Copy invoice",
|
||||
"subtitle_invoice": "Create a new invoice",
|
||||
"subtitle_proforma": "Create a proforma invoice (no bookkeeping)",
|
||||
"subtitle_delivery_note": "Create a delivery note (without prices)",
|
||||
"subtitle_copy": "Create a new invoice from reused content",
|
||||
"copy_notice": "The content was copied from {number}. A new draft is created with a new date and invoice number. Check the customer, references, and ROT/RUT details before continuing.",
|
||||
"copy_load_failed_title": "Could not copy the invoice",
|
||||
"copy_load_failed_description": "The invoice does not exist or cannot be used as a template.",
|
||||
@@ -3471,17 +3504,13 @@
|
||||
"vat_label": "VAT",
|
||||
"vat_taxed_where_performed_hint": "Swedish VAT on an invoice to a foreign business applies only to services taxed where they are performed, for example hotel, restaurant, passenger transport, property services or admission to cultural and sports events (ML 6 kap.).",
|
||||
"row_label": "Row {index}",
|
||||
"add_row": "Add row",
|
||||
"add_text_row": "Add text row",
|
||||
"text_row_label": "Free text",
|
||||
"text_row_placeholder": "Explanatory text: leave empty for a blank row",
|
||||
"remove_row_aria": "Remove row",
|
||||
"remove_row": "Remove row",
|
||||
"row_actions_aria": "Row actions",
|
||||
"drag_handle_aria": "Drag to move the row",
|
||||
"notes_card_title": "Notes",
|
||||
"notes_placeholder": "E.g. payment terms or thanks for the collaboration...",
|
||||
"details_card_title": "Invoice details",
|
||||
"document_type_label": "Document type",
|
||||
"doctype_invoice": "Invoice",
|
||||
"doctype_proforma": "Proforma invoice",
|
||||
@@ -3490,7 +3519,6 @@
|
||||
"invoice_date_label": "Invoice date",
|
||||
"due_date_label": "Due date",
|
||||
"delivery_date_label": "Delivery date",
|
||||
"delivery_date_placeholder": "If different from invoice date",
|
||||
"your_reference_label": "Your reference",
|
||||
"your_reference_placeholder": "Customer contact person",
|
||||
"our_reference_label": "Our reference",
|
||||
@@ -3509,19 +3537,15 @@
|
||||
"total_label": "Total",
|
||||
"review_and_create": "Review & create",
|
||||
"save_as_draft": "Save as draft",
|
||||
"save_as_draft_short": "Draft",
|
||||
"save_as_draft_tooltip": "Saves without an invoice number: can be deleted later",
|
||||
"toast_draft_saved_title": "Draft saved",
|
||||
"toast_draft_saved_description": "The draft is in your invoice list. Open it to review and create the invoice.",
|
||||
"save_draft_failed_title": "Could not save draft",
|
||||
"title_edit": "Edit draft",
|
||||
"subtitle_edit": "Change the draft and save. The invoice number is unchanged.",
|
||||
"save_changes": "Save changes",
|
||||
"toast_draft_updated_title": "Draft updated",
|
||||
"toast_draft_updated_description": "Your changes have been saved.",
|
||||
"update_failed_title": "Could not save your changes",
|
||||
"mode_invoice": "Invoice",
|
||||
"mode_self_billed": "Self-billing",
|
||||
"viewer_disabled_tooltip": "You only have read-only access to this company",
|
||||
"review_dialog_title_invoice": "Review invoice",
|
||||
"review_dialog_title_proforma": "Review proforma invoice",
|
||||
@@ -3557,8 +3581,6 @@
|
||||
"validation_invoice_date_required": "Invoice date required",
|
||||
"validation_due_date_required": "Due date required",
|
||||
"validation_min_one_row": "At least one row required",
|
||||
"validation_toast_title": "Check the details",
|
||||
"validation_toast_description": "A field is missing or invalid. Fix the highlighted fields and try again.",
|
||||
"article_search_empty": "No article matches your search",
|
||||
"deduction_menu_label": "Tax reduction",
|
||||
"deduction_none": "None",
|
||||
@@ -3589,6 +3611,11 @@
|
||||
"review_customer_missing_description": "Reload the page and try again. Contact support if the problem persists."
|
||||
},
|
||||
"invoice_review": {
|
||||
"forval_currency": "Currency {currency}",
|
||||
"forval_ore_on": "Rounded to whole kronor",
|
||||
"forval_ore_off": "No öre rounding",
|
||||
"forval_link_auto": "Stripe payment link created on send",
|
||||
"forval_link_manual": "Payment link attached",
|
||||
"assigned_number_prefix": "Will be assigned invoice number",
|
||||
"accrual_line_info": "Accrued {from} to {to}",
|
||||
"customer_type_individual": "Private individual",
|
||||
|
||||
+42
-15
@@ -3420,6 +3420,43 @@
|
||||
"cancel": "Avbryt"
|
||||
},
|
||||
"invoice_editor": {
|
||||
"amount_label": "Belopp",
|
||||
"rows_count": "{count, plural, =1 {1 rad} other {# rader}}",
|
||||
"customer_done": "vald",
|
||||
"optional_label": "(valfritt)",
|
||||
"section_forval": "Förval",
|
||||
"forval_edit": "Ändra förval",
|
||||
"entry_placeholder": "Skriv fritt eller sök artikel ...",
|
||||
"entry_aria": "Ny fakturarad",
|
||||
"entry_hint_matches": "Pil ned väljer artikel, Enter lägger till som fritextrad",
|
||||
"entry_hint_free": "Enter lägger till som fritextrad",
|
||||
"row_menu_pick_article": "Välj artikel",
|
||||
"remove_row_aria_named": "Ta bort rad: {description}",
|
||||
"validation_price_invalid": "Ange ett à-pris",
|
||||
"chip_currency": "Valuta {currency}",
|
||||
"chip_due_days": "Förfaller {days} dagar ({date})",
|
||||
"chip_due_date": "Förfaller {date}",
|
||||
"chip_received": "Mottagen {date}",
|
||||
"chip_delivery": "Leveransdatum {date}",
|
||||
"chip_your_reference": "Er referens: {reference}",
|
||||
"chip_stripe_auto": "Stripe-länk skapas vid utskick",
|
||||
"chip_payment_link": "Betalningslänk inlagd",
|
||||
"chip_ore_off": "Ingen öresavrundning",
|
||||
"chip_dims": "Dimensioner: {dims}",
|
||||
"next_step_prefix": "Nästa steg:",
|
||||
"next_step_customer": "välj kund",
|
||||
"next_step_invoice_date": "ange fakturadatum",
|
||||
"next_step_due_date": "ange förfallodatum",
|
||||
"next_step_add_row": "lägg till en fakturarad",
|
||||
"next_step_row_incomplete": "komplettera rad {index}",
|
||||
"next_step_payment_link": "kontrollera betalningslänken",
|
||||
"next_step_personnummer": "ange personnummer för skattereduktionen",
|
||||
"next_step_housing": "ange fastighetsbeteckning",
|
||||
"next_step_external_number": "ange kundens fakturanummer",
|
||||
"next_step_received_date": "ange mottagningsdatum",
|
||||
"ready_create": "Klar att granska: allt annat har smarta förval.",
|
||||
"ready_edit": "Klart att spara.",
|
||||
"ready_self_billed": "Klar att registrera.",
|
||||
"row_menu_set_account": "Ange bokföringskonto",
|
||||
"row_menu_remove_account": "Ta bort bokföringskonto",
|
||||
"row_menu_set_dimensions": "Ange kostnadsställe/projekt",
|
||||
@@ -3442,10 +3479,6 @@
|
||||
"title_proforma": "Ny proformafaktura",
|
||||
"title_delivery_note": "Ny följesedel",
|
||||
"title_copy": "Kopiera faktura",
|
||||
"subtitle_invoice": "Skapa en ny faktura",
|
||||
"subtitle_proforma": "Skapa en proformafaktura (ingen bokföring)",
|
||||
"subtitle_delivery_note": "Skapa en följesedel (utan priser)",
|
||||
"subtitle_copy": "Skapa en ny faktura med återanvänt innehåll",
|
||||
"copy_notice": "Innehållet har kopierats från {number}. Ett nytt utkast skapas med nytt datum och nytt fakturanummer. Kontrollera kund, referenser och ROT/RUT-uppgifter innan du fortsätter.",
|
||||
"copy_load_failed_title": "Kunde inte kopiera fakturan",
|
||||
"copy_load_failed_description": "Fakturan finns inte eller kan inte användas som mall.",
|
||||
@@ -3471,17 +3504,13 @@
|
||||
"vat_label": "Moms",
|
||||
"vat_taxed_where_performed_hint": "Svensk moms på en faktura till ett utländskt företag gäller bara tjänster som beskattas där de utförs, till exempel hotell, restaurang, persontransport, fastighetstjänst eller entré till kultur- och sportevenemang (ML 6 kap.).",
|
||||
"row_label": "Rad {index}",
|
||||
"add_row": "Lägg till rad",
|
||||
"add_text_row": "Lägg till textrad",
|
||||
"text_row_label": "Fritext",
|
||||
"text_row_placeholder": "Förklarande text: lämna tom för en tomrad",
|
||||
"remove_row_aria": "Ta bort rad",
|
||||
"remove_row": "Ta bort rad",
|
||||
"row_actions_aria": "Radåtgärder",
|
||||
"drag_handle_aria": "Dra för att flytta raden",
|
||||
"notes_card_title": "Anteckningar",
|
||||
"notes_placeholder": "T.ex. betalningsvillkor eller tack för samarbetet...",
|
||||
"details_card_title": "Fakturadetaljer",
|
||||
"document_type_label": "Dokumenttyp",
|
||||
"doctype_invoice": "Faktura",
|
||||
"doctype_proforma": "Proformafaktura",
|
||||
@@ -3490,7 +3519,6 @@
|
||||
"invoice_date_label": "Fakturadatum",
|
||||
"due_date_label": "Förfallodatum",
|
||||
"delivery_date_label": "Leveransdatum",
|
||||
"delivery_date_placeholder": "Om det skiljer sig från fakturadatum",
|
||||
"your_reference_label": "Er referens",
|
||||
"your_reference_placeholder": "Kontaktperson hos kund",
|
||||
"our_reference_label": "Vår referens",
|
||||
@@ -3509,19 +3537,15 @@
|
||||
"total_label": "Totalt",
|
||||
"review_and_create": "Granska & skapa",
|
||||
"save_as_draft": "Spara som utkast",
|
||||
"save_as_draft_short": "Utkast",
|
||||
"save_as_draft_tooltip": "Sparar utan fakturanummer: kan tas bort senare",
|
||||
"toast_draft_saved_title": "Utkast sparat",
|
||||
"toast_draft_saved_description": "Utkastet finns i fakturalistan. Öppna det för att granska och skapa fakturan.",
|
||||
"save_draft_failed_title": "Kunde inte spara utkast",
|
||||
"title_edit": "Redigera utkast",
|
||||
"subtitle_edit": "Ändra utkastet och spara. Inget fakturanummer ändras.",
|
||||
"save_changes": "Spara ändringar",
|
||||
"toast_draft_updated_title": "Utkast uppdaterat",
|
||||
"toast_draft_updated_description": "Ändringarna har sparats.",
|
||||
"update_failed_title": "Kunde inte spara ändringarna",
|
||||
"mode_invoice": "Faktura",
|
||||
"mode_self_billed": "Självfaktura",
|
||||
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"review_dialog_title_invoice": "Granska faktura",
|
||||
"review_dialog_title_proforma": "Granska proformafaktura",
|
||||
@@ -3557,8 +3581,6 @@
|
||||
"validation_invoice_date_required": "Fakturadatum krävs",
|
||||
"validation_due_date_required": "Förfallodatum krävs",
|
||||
"validation_min_one_row": "Minst en rad krävs",
|
||||
"validation_toast_title": "Kontrollera uppgifterna",
|
||||
"validation_toast_description": "Något fält saknas eller är felaktigt. Rätta de markerade fälten och försök igen.",
|
||||
"article_search_empty": "Ingen artikel matchar sökningen",
|
||||
"deduction_menu_label": "Skattereduktion",
|
||||
"deduction_none": "Ingen",
|
||||
@@ -3589,6 +3611,11 @@
|
||||
"review_customer_missing_description": "Ladda om sidan och försök igen. Kontakta support om det inte hjälper."
|
||||
},
|
||||
"invoice_review": {
|
||||
"forval_currency": "Valuta {currency}",
|
||||
"forval_ore_on": "Öresavrundning till hel krona",
|
||||
"forval_ore_off": "Ingen öresavrundning",
|
||||
"forval_link_auto": "Stripe-betalningslänk skapas vid utskick",
|
||||
"forval_link_manual": "Betalningslänk bifogas",
|
||||
"assigned_number_prefix": "Tilldelas fakturanummer",
|
||||
"accrual_line_info": "Periodiseras {from} till {to}",
|
||||
"customer_type_individual": "Privatperson",
|
||||
|
||||
Reference in New Issue
Block a user