feat(invoices,year-end): four byrå-feedback fixes (validation feedback, moms gate, klarmarkera, article search) (#1641)

* fix(invoices): surface validation errors instead of a silent dead submit button

A missing unit (or any other Zod failure) blocked both Granska & skapa and
Spara som utkast with zero feedback: handleSubmit had no onInvalid callback,
the buttons stayed enabled, and the unit field rendered no inline error.
Reported by a byra user whose client could not save any invoice.

- onInvalid handler on all three submit paths: destructive toast plus scroll
  to the first inline error
- inline error text under the unit select and quantity input (the only line
  fields that had none)
- same treatment in NewRecurringScheduleDialog, including inline errors on
  its item rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(supplier-invoices): stop defaulting 25 % moms for icke momsregistrerade companies

The registration form hard-coded vat_rate 0.25 on the initial line, added
rows, AI prefill fallback and konto defaults, regardless of
company_settings.vat_registered. A non-VAT-registered business that missed
the prefilled rate booked ingaende moms (2641) it has no right to deduct
(ML 8 kap. 3 \u00a7). The customer-invoice side already gates on the same flag;
the supplier side ignored it.

- form: read vat_registered from /api/settings; when false, all moms
  controls (rate cells, per-line moms, totals rows) are hidden and every
  line is forced to 0 %, including late AI prefills
- reverse charge keeps its rate controls: self-assessment is a separate
  obligation from deduction
- route: 400 SI_CREATE_INVALID_INPUT when a non-registered company posts a
  line with vat_rate/vat_amount > 0 (API/MCP defense in depth), and an
  omitted vat_rate now defaults to 0 instead of 25 % for those companies
- tests: guard rejection, reverse-charge pass-through, 0-default; existing
  POST tests updated for the new settings lookup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(year-end): klarmarkera imported years already closed in a previous system

SIE-imported historical fiscal years land with is_closed = false and no
closing entry, so the year-end page lists every migrated year as pending
bokslut even though the bokslut was done in the old software. There was no
sanctioned way to mark them done: closePeriod hard-requires locked_at and
closing_entry_id.

- migration: fiscal_periods.closed_externally boolean (audit clarity:
  distinguishes a year-end run here from a close done elsewhere)
- markPeriodClosedExternally(): closes + locks without a closing entry;
  refuses already-closed periods, periods with their own closing entry,
  periods that have not ended, and periods with unbooked bank transactions
  (same stranding guard as lockPeriod); writes the immutable audit_log entry
- POST /api/bookkeeping/fiscal-periods/[id]/close-external (requireWrite)
- year-end page: one attn line on the preflight step with a confirm dialog
  describing the outcome; the marked year drops out of the eligible list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): searchable article picker on invoice lines

The article field was a plain Radix Select whose only matching is
label-prefix typeahead: for numbered articles that means number-only lookup,
and typing "skruv" found nothing. Byra feedback: name search would help a
lot for users with real article catalogs.

New ArticleCombobox (input-trigger dropdown, same pattern as
AccountCombobox): free-text search over name + article number,
diacritics-folded via foldText, keyboard navigation, pinned "Egen rad"
free-text option, browse-all on focus like the Select it replaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log klarmarkera pg-test decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address skeptic and compliance-review findings on PR #1641

- ArticleCombobox: keyboard focus no longer auto-opens the list, opening
  highlights the committed selection, typing highlights the first match,
  and re-selecting the current value is a no-op. Previously Tab+Enter
  silently detached the article and wiped its revenue-account override.
- Supplier invoice prefill for icke momsregistrerade: the zeroing effect now
  grosses the net amount up by the extracted rate before forcing 0 %, so the
  booked cost and 2440 keep the full att-betala amount instead of
  understating both by the moms.
- markPeriodClosedExternally: only migrated periods qualify (must contain
  SIE-imported verifikat or no verifikat at all); the update carries an
  is_closed=false predicate so a concurrent normal close cannot be
  overwritten; confirm dialog now names the reporting consequences.
- Route comment: honest scope (this route only; v1/inbox/MCP sweep is a
  follow-up) and current-law citation (13 kap. ML 2023:200).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: use roundOre for the icke-momsregistrerad gross-up (ratchet guard)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-17 12:02:43 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent caa0c3b41d
commit dfb34a01d9
16 changed files with 1075 additions and 92 deletions
+252
View File
@@ -0,0 +1,252 @@
'use client'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { foldText } from '@/lib/bookkeeping/account-search'
export interface ArticleComboboxItem {
id: string
article_number: string | null
name: string
}
interface ArticleComboboxProps {
/** Selected article id, or null for a free-text line. */
value: string | null
/** Already sorted by the caller (sortArticles). */
articles: ArticleComboboxItem[]
/** Receives the picked article id, or 'none' for the free-text option. */
onChange: (value: string) => void
/** Label for the pinned free-text option ("Egen rad"). */
freeTextLabel: string
/** Trigger placeholder when nothing is selected. */
placeholder: string
/** Empty-state text when the search matches nothing. */
emptyLabel: string
disabled?: boolean
ariaLabel?: string
}
function articleLabel(a: ArticleComboboxItem): string {
return a.article_number ? `${a.article_number}: ${a.name}` : a.name
}
/**
* Searchable article picker for invoice lines. Replaces the plain Select whose
* only matching was Radix's label-prefix typeahead: for numbered articles that
* meant number-only lookup, and typing "skruv" found nothing. Free-text search
* here matches both name and article number, diacritics-folded (foldText), on
* the already-loaded article list. Same input-trigger dropdown pattern as
* AccountCombobox.
*/
export default function ArticleCombobox({
value,
articles,
onChange,
freeTextLabel,
placeholder,
emptyLabel,
disabled = false,
ariaLabel,
}: ArticleComboboxProps) {
const selected = value ? articles.find((a) => a.id === value) ?? null : null
// Free-text lines display the "Egen rad" label, matching the Select this
// replaces (which pinned value 'none' and never showed a placeholder).
const selectedLabel = selected ? articleLabel(selected) : freeTextLabel
const [search, setSearch] = useState(selectedLabel)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
// 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)
const containerRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
// True while a pointer interaction is what is about to focus the field.
// Keyboard focus (Tab) must NOT auto-open: with the list open a bare Enter
// would select the highlighted row, and the old Select treated Tab+Enter as
// a no-op. Pointer focus keeps the click-to-browse behavior.
const pointerDownRef = useRef(false)
// Sync external value changes (applyArticle, draft restore) into the field.
useEffect(() => {
setSearch(selectedLabel)
// selectedLabel is derived from value + articles; both belong here.
}, [selectedLabel])
const filtered = useMemo(() => {
const q = foldText(search.trim())
// A committed selection sits in the field as its full label; treating it
// as a filter would show exactly one row. Browse instead.
if (!q || !hasTyped) return articles
return articles.filter((a) =>
foldText(`${a.article_number ?? ''} ${a.name}`).includes(q),
)
}, [articles, search, hasTyped])
// Options list: the free-text "Egen rad" choice stays pinned on top.
type Option = { key: string; label: string; muted: boolean }
const options = useMemo<Option[]>(
() => [
{ key: 'none', label: freeTextLabel, muted: true },
...filtered.map((a) => ({ key: a.id, label: articleLabel(a), muted: false })),
],
[filtered, freeTextLabel],
)
// While typing, highlight the first actual match (index 1: index 0 is the
// pinned "Egen rad"), so type-and-Enter picks the searched article instead
// of silently detaching the line to free text.
useEffect(() => {
setHighlightedIndex(hasTyped && filtered.length > 0 ? 1 : 0)
}, [options.length, search, hasTyped, filtered.length])
// Opening on a committed selection starts the highlight ON that selection,
// like the Select this replaces, so Enter re-confirms instead of switching.
const openList = useCallback(() => {
const currentKey = value ?? 'none'
const idx = options.findIndex((o) => o.key === currentKey)
setHighlightedIndex(idx >= 0 ? idx : 0)
setIsOpen(true)
}, [options, value])
useEffect(() => {
if (!isOpen || !listRef.current) return
listRef.current
.querySelector('[data-highlighted="true"]')
?.scrollIntoView({ block: 'nearest' })
}, [highlightedIndex, isOpen])
useEffect(() => {
function handleClickOutside(e: MouseEvent | TouchEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('touchstart', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('touchstart', handleClickOutside)
}
}, [])
const select = useCallback(
(option: Option) => {
// Re-selecting the committed value is a no-op close: applyArticle
// re-applies the article's description/price/unit, which would clobber
// per-line edits, and re-selecting "Egen rad" on a free-text line would
// needlessly null the article link. The old Radix Select behaved the
// same (onValueChange only fires on an actual change).
if (option.key !== (value ?? 'none')) {
onChange(option.key)
}
setSearch(option.label)
setHasTyped(false)
setIsOpen(false)
},
[onChange, value],
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
e.preventDefault()
openList()
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex((prev) => Math.min(prev + 1, options.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
break
case 'Enter':
e.preventDefault()
if (options[highlightedIndex]) select(options[highlightedIndex])
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
break
}
}
const handleBlur = () => {
setIsOpen(false)
// Delay so a dropdown mousedown wins, then snap the field back to the
// committed selection: a half-typed search must not linger as a label.
setTimeout(() => {
setSearch(selectedLabel)
setHasTyped(false)
}, 150)
}
return (
<div ref={containerRef} className="relative">
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setHasTyped(true)
if (!isOpen) setIsOpen(true)
}}
onPointerDown={() => {
pointerDownRef.current = true
}}
onClick={() => {
// Clicking an already-focused field reopens the list (onFocus will
// not fire again in that case).
if (!isOpen) openList()
}}
onFocus={(e) => {
setHasTyped(false)
if (pointerDownRef.current) openList()
pointerDownRef.current = false
// Typing should replace the committed label, not append to it.
e.currentTarget.select()
}}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoComplete="off"
disabled={disabled}
role="combobox"
aria-expanded={isOpen}
aria-label={ariaLabel}
/>
{isOpen && !disabled && (
<div
ref={listRef}
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}
type="button"
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'
} ${option.muted ? 'text-muted-foreground' : ''}`}
onMouseDown={(e) => {
e.preventDefault()
select(option)
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
{option.label}
</button>
))}
{filtered.length === 0 && (
<p className="px-2 py-1.5 text-sm text-muted-foreground">{emptyLabel}</p>
)}
</div>
)}
</div>
)
}
+41 -19
View File
@@ -31,6 +31,7 @@ import {
} from '@/components/invoices/line-vat-rates'
import { AttnLine } from '@/components/ui/attn-line'
import { sortArticles } from '@/lib/articles/sort'
import ArticleCombobox from '@/components/invoices/ArticleCombobox'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Copy } from 'lucide-react'
@@ -1243,6 +1244,24 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
}
// A failed Zod validation is otherwise invisible: handleSubmit never reaches
// onSubmit, the buttons stay enabled and look normal, and the only signal is
// inline error text the user may have scrolled past. Toast + scroll so a
// blocked "Granska & skapa" / "Spara som utkast" never reads as a dead button.
function onInvalidSubmit(_errors: unknown, event?: React.BaseSyntheticEvent) {
toast({
title: t('validation_toast_title'),
description: t('validation_toast_description'),
variant: 'destructive',
})
const root = (event?.target as HTMLElement | null)?.closest('form')
// The inline error paragraphs render on the next React commit; scroll after.
setTimeout(() => {
const firstError = (root ?? document).querySelector('p.text-destructive')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
}
// "Spara som utkast": save an unnumbered draft (save_as_draft) without the
// review dialog. The invoice gets no F-number and fires no invoice.created
// until the user opens it and clicks "Granska & skapa" (finalize). Same
@@ -1539,7 +1558,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className={bare ? 'space-y-6' : 'space-y-6 pb-28 md:pb-0'}>
<form onSubmit={handleSubmit(onSubmit, onInvalidSubmit)} className={bare ? 'space-y-6' : 'space-y-6 pb-28 md:pb-0'}>
<div className="grid gap-6 lg:grid-cols-3 lg:items-start">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
@@ -1777,22 +1796,15 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
name={`items.${index}.article_id`}
control={control}
render={({ field }) => (
<Select
value={field.value ?? 'none'}
onValueChange={(v) => applyArticle(index, v)}
>
<SelectTrigger>
<SelectValue placeholder={t('article_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('article_free_text')}</SelectItem>
{articles.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.article_number ? `${a.article_number}: ${a.name}` : a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<ArticleCombobox
value={field.value ?? null}
articles={articles}
onChange={(v) => applyArticle(index, v)}
freeTextLabel={t('article_free_text')}
placeholder={t('article_placeholder')}
emptyLabel={t('article_search_empty')}
ariaLabel={t('article_label')}
/>
)}
/>
</div>
@@ -1843,6 +1855,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
className="text-right tabular-nums"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
{errors.items?.[index]?.quantity && (
<p className="text-sm text-destructive">
{errors.items[index].quantity?.message}
</p>
)}
</div>
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('unit_label')}</Label>
@@ -1864,6 +1881,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</Select>
)}
/>
{errors.items?.[index]?.unit && (
<p className="text-sm text-destructive">
{errors.items[index].unit?.message}
</p>
)}
</div>
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">{t('unit_price_label')}</Label>
@@ -2544,7 +2566,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
size="lg"
disabled={isSubmitting || isSavingDraft || isFormSubmitting || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : t('save_as_draft_tooltip')}
onClick={handleSubmit(saveDraftData)}
onClick={handleSubmit(saveDraftData, onInvalidSubmit)}
>
{isSavingDraft ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('save_as_draft')}
@@ -2572,7 +2594,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
type="button"
variant="outline"
disabled={isSubmitting || isSavingDraft || isFormSubmitting || !canWrite}
onClick={handleSubmit(saveDraftData)}
onClick={handleSubmit(saveDraftData, onInvalidSubmit)}
>
{isSavingDraft ? <Loader2 className="h-4 w-4 animate-spin" /> : t('save_as_draft_short')}
</Button>
@@ -219,6 +219,21 @@ function NewRecurringScheduleForm({
}
}
// Mirror InvoiceEditor: a failed validation must never look like a dead
// button. Toast, then scroll the first inline error into view once rendered.
function onInvalidSubmit(_errors: unknown, event?: React.BaseSyntheticEvent) {
toast({
title: t('validation_toast_title'),
description: t('validation_toast_description'),
variant: 'destructive',
})
const root = (event?.target as HTMLElement | null)?.closest('form')
setTimeout(() => {
const firstError = (root ?? document).querySelector('p.text-destructive')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
}
const items = watch('items')
const watchCurrency = watch('currency')
// Automatic sending requires a customer email; without one the cron would
@@ -246,7 +261,7 @@ function NewRecurringScheduleForm({
const subtotal = Math.round(subtotalRaw * 100) / 100
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<form onSubmit={handleSubmit(onSubmit, onInvalidSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('schedule_card_title')}</CardTitle>
@@ -459,6 +474,11 @@ function NewRecurringScheduleForm({
placeholder={t('description_placeholder')}
{...register(`items.${index}.description`)}
/>
{errors.items?.[index]?.description && (
<p className="text-sm text-destructive mt-1">
{errors.items[index].description?.message}
</p>
)}
</div>
<div className="col-span-3 sm:col-span-2">
<Input
@@ -468,6 +488,11 @@ function NewRecurringScheduleForm({
className="tabular-nums"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
{errors.items?.[index]?.quantity && (
<p className="text-sm text-destructive mt-1">
{errors.items[index].quantity?.message}
</p>
)}
</div>
<div className="col-span-3 sm:col-span-1">
<Controller
@@ -488,6 +513,11 @@ function NewRecurringScheduleForm({
</Select>
)}
/>
{errors.items?.[index]?.unit && (
<p className="text-sm text-destructive mt-1">
{errors.items[index].unit?.message}
</p>
)}
</div>
<div className="col-span-4 sm:col-span-3">
<Input
@@ -334,6 +334,11 @@ export default function NewSupplierInvoiceForm({
// JournalEntryForm. defaultDims is the invoice-level default bag; per-item
// bags live on the form's items and merge over it server-side.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
// Icke momsregistrerad verksamhet has no right to deduct input VAT: the
// moms controls disappear and every line books at 0 % (the gross amount IS
// the cost). Defaults true so registered companies keep the 25 % prefill
// while /api/settings is still in flight.
const [vatRegistered, setVatRegistered] = useState(true)
const [defaultDims, setDefaultDims] = useState<Record<string, string>>({})
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [periodsLoaded, setPeriodsLoaded] = useState(false)
@@ -579,6 +584,9 @@ export default function NewSupplierInvoiceForm({
// time) and a silent default misbooks: leave empty so the user
// (or the supplier default) makes the call.
account_number: '',
// Deliberately unconditional: for icke momsregistrerade the
// zeroing effect below grosses the net amount up by this rate
// before forcing it to 0, so the rate must arrive intact.
vat_rate: vatRateFromAi(li.vatRate),
accrual_period_start: withAccrual ? (sps as string) : undefined,
accrual_period_end: withAccrual ? (spe as string) : undefined,
@@ -812,6 +820,9 @@ export default function NewSupplierInvoiceForm({
}
if (typeof data?.ore_rounding === 'boolean') setOreRounding(data.ore_rounding)
setDimensionsEnabled(data?.dimensions_enabled === true)
// Only an explicit false gates: a missing column or failed fetch keeps
// the registered-company behavior.
if (data?.vat_registered === false) setVatRegistered(false)
} catch {
// Default to enskild_firma / accrual, dimension affordances hidden
}
@@ -845,7 +856,7 @@ export default function NewSupplierInvoiceForm({
// (inferVatTreatment) expect a number.
const acct = accounts.find((a) => a.account_number === accountNumber)
const defaultRate = acct?.default_vat_rate == null ? null : Number(acct.default_vat_rate)
if (!watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)) {
if (vatRegistered && !watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)) {
setValue(`items.${index}.vat_rate`, defaultRate, { shouldDirty: true })
}
// Särskild löneskatt only applies to 741x pension premiums: leaving the
@@ -882,6 +893,31 @@ export default function NewSupplierInvoiceForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchedReverseCharge])
// Force every line to 0 % moms for icke momsregistrerade companies: the
// default line, AI prefills and konto defaults all assume 25 % otherwise.
// Re-runs after the inbox prefill lands so a late extraction can't
// reintroduce a rate.
//
// The amount is grossed up in the same pass: an amount paired with a
// non-zero rate is a NET amount (AI line totals are exkl moms, and the
// visible column said "Belopp (exkl.)" while the rate stood). For a company
// with no deduction right the moms is part of the cost, so net at 25 %
// becomes gross at 0 %; zeroing the rate alone would understate both the
// expense and 2440 by exactly the moms.
useEffect(() => {
if (vatRegistered) return
const items = getValues('items') ?? []
items.forEach((item, index) => {
if (item.vat_rate !== 0) {
if (item.amount) {
setValue(`items.${index}.amount`, roundOre(item.amount * (1 + item.vat_rate)))
}
setValue(`items.${index}.vat_rate`, 0)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vatRegistered, hasPrefilled])
function isAccrualOpen(index: number): boolean {
return watchedItems?.[index]?.accrual_balance_account != null
}
@@ -1056,6 +1092,11 @@ export default function NewSupplierInvoiceForm({
)
}
// Reverse charge keeps its rate controls even for icke momsregistrerade
// (self-assessment is a separate obligation from deduction); everything
// else moms-related disappears when the company isn't VAT-registered.
const vatColsVisible = vatRegistered || watchedReverseCharge
const itemTotals = (watchedItems || []).map((item) => {
const lineTotal = Math.round((item.amount || 0) * 100) / 100
// Reverse charge: VAT is self-assessed at reverse_charge_rate (25% default),
@@ -1889,7 +1930,7 @@ export default function NewSupplierInvoiceForm({
size="sm"
className="w-full sm:w-auto"
onClick={() =>
append({ description: '', amount: 0, account_number: '', vat_rate: 0.25, reverse_charge_rate: 0.25 })
append({ description: '', amount: 0, account_number: '', vat_rate: vatRegistered ? 0.25 : 0, reverse_charge_rate: 0.25 })
}
>
<Plus className="mr-2 h-4 w-4" />
@@ -2017,9 +2058,13 @@ export default function NewSupplierInvoiceForm({
<tr className="border-b text-left">
<th className="pb-2 w-28">{t('col_account')}</th>
<th className="pb-2">{t('col_description')}</th>
<th className="pb-2 w-32">{t('col_amount_excl')}</th>
<th className="pb-2 w-36">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</th>
<th className="pb-2 w-24 text-right">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</th>
<th className="pb-2 w-32">{vatColsVisible ? t('col_amount_excl') : t('col_amount')}</th>
{vatColsVisible && (
<>
<th className="pb-2 w-36">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</th>
<th className="pb-2 w-24 text-right">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</th>
</>
)}
<th className="pb-2 w-8"></th>
</tr>
</thead>
@@ -2072,28 +2117,32 @@ export default function NewSupplierInvoiceForm({
)}
/>
</td>
<td className="py-2 pr-2">
{watchedReverseCharge ? (
<Controller
name={`items.${index}.reverse_charge_rate`}
control={control}
render={({ field: f }) => (
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
{vatColsVisible && (
<>
<td className="py-2 pr-2">
{watchedReverseCharge ? (
<Controller
name={`items.${index}.reverse_charge_rate`}
control={control}
render={({ field: f }) => (
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
)}
/>
) : (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
)}
/>
) : (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
)}
</td>
<td className="py-2 pr-2 text-right tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</td>
</td>
<td className="py-2 pr-2 text-right tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</td>
</>
)}
<td className="py-2 pt-3">
<div className="flex items-center">
{dimensionsEnabled && (
@@ -2142,21 +2191,21 @@ export default function NewSupplierInvoiceForm({
</tr>
{canUseAccrual && isAccrualOpen(index) && (
<tr className={cn(dimensionsEnabled && isDimOpen(index) ? 'border-0' : 'border-b last:border-0')}>
<td colSpan={6} className="pb-3">
<td colSpan={vatColsVisible ? 6 : 4} className="pb-3">
{renderAccrualPanel(index, `accrual-desktop-${index}`)}
</td>
</tr>
)}
{dimensionsEnabled && isDimOpen(index) && (
<tr className={cn(slpRowVisible(index) ? 'border-0' : 'border-b last:border-0')}>
<td colSpan={6} className="pb-3">
<td colSpan={vatColsVisible ? 6 : 4} className="pb-3">
{renderDimensionsPanel(index)}
</td>
</tr>
)}
{slpRowVisible(index) && (
<tr className="border-b last:border-0">
<td colSpan={6} className="pb-3">
<td colSpan={vatColsVisible ? 6 : 4} className="pb-3">
{renderSlpPanel(index)}
</td>
</tr>
@@ -2243,9 +2292,9 @@ export default function NewSupplierInvoiceForm({
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className={vatColsVisible ? 'grid grid-cols-2 gap-3' : 'grid grid-cols-1 gap-3'}>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('col_amount_excl')}</Label>
<Label className="text-xs text-muted-foreground">{vatColsVisible ? t('col_amount_excl') : t('col_amount')}</Label>
<Controller
name={`items.${index}.amount`}
control={control}
@@ -2262,33 +2311,37 @@ export default function NewSupplierInvoiceForm({
)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</Label>
{watchedReverseCharge ? (
<Controller
name={`items.${index}.reverse_charge_rate`}
control={control}
render={({ field: f }) => (
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
)}
/>
) : (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
)}
{vatColsVisible && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}</Label>
{watchedReverseCharge ? (
<Controller
name={`items.${index}.reverse_charge_rate`}
control={control}
render={({ field: f }) => (
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
)}
/>
) : (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<VatRateCell value={f.value} onChange={f.onChange} />
)}
/>
)}
</div>
)}
</div>
{vatColsVisible && (
<div className="pt-1 border-t flex items-center justify-between">
<span className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</span>
<span className="tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</span>
</div>
</div>
<div className="pt-1 border-t flex items-center justify-between">
<span className="text-xs text-muted-foreground">{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}</span>
<span className="tabular-nums text-muted-foreground">
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</span>
</div>
)}
{canUseAccrual && isAccrualOpen(index) &&
renderAccrualPanel(index, `accrual-mobile-${index}`)}
{dimensionsEnabled && isDimOpen(index) && renderDimensionsPanel(index)}
@@ -2321,16 +2374,20 @@ export default function NewSupplierInvoiceForm({
{/* Computed totals */}
<div className="mt-4 pt-4 border-t space-y-2">
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(subtotal, watchedCurrency)}</span>
</div>
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">
{watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}
</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(totalVat, watchedCurrency)}</span>
</div>
{vatColsVisible && (
<>
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(subtotal, watchedCurrency)}</span>
</div>
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">
{watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}
</span>
<span className="tabular-nums sm:w-32 text-right">{formatCurrency(totalVat, watchedCurrency)}</span>
</div>
</>
)}
{displayRounding.applies && (
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">{t('ore_rounding_label')}</span>