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:
Jakob Wennberg
2026-08-18 09:56:35 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 93e99012d7
commit 2b5b813b7a
12 changed files with 2879 additions and 1274 deletions
+5 -2
View File
@@ -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}
+23 -1
View File
@@ -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'
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">
+4 -1
View File
@@ -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([])
})
})
+207
View File
@@ -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))
}