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:
co-authored by
Claude Fable 5
parent
caa0c3b41d
commit
dfb34a01d9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user