From 2b5b813b7a438c50d1bf54ca0399ce501486e2f3 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 18 Aug 2026 09:56:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(invoices):=20rebuild=20the=20invoice=20edi?= =?UTF-8?q?tor=20as=20the=20snabbfl=C3=B6de=20single=20column=20(#1654)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * fix(invoices): wrap sticky-bar actions so they fit small viewports Co-Authored-By: Claude Fable 5 * 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- app/globals.css | 12 + components/agent/AgentTrigger.tsx | 7 +- components/invoices/ArticleCombobox.tsx | 24 +- components/invoices/InvoiceEditor.tsx | 2809 +++++++++-------- components/invoices/InvoiceReviewContent.tsx | 25 + components/invoices/NewInvoiceDialog.tsx | 5 +- .../__tests__/invoice-editor-flow.test.ts | 272 ++ components/invoices/invoice-editor-flow.ts | 207 ++ lib/invoices/__tests__/editor-payload.test.ts | 505 +++ lib/invoices/editor-payload.ts | 173 + messages/en.json | 57 +- messages/sv.json | 57 +- 12 files changed, 2879 insertions(+), 1274 deletions(-) create mode 100644 components/invoices/__tests__/invoice-editor-flow.test.ts create mode 100644 components/invoices/invoice-editor-flow.ts create mode 100644 lib/invoices/__tests__/editor-payload.test.ts create mode 100644 lib/invoices/editor-payload.ts diff --git a/app/globals.css b/app/globals.css index cc1dd1d4..64a510b8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 diff --git a/components/agent/AgentTrigger.tsx b/components/agent/AgentTrigger.tsx index e8acf569..fb483c07 100644 --- a/components/agent/AgentTrigger.tsx +++ b/components/agent/AgentTrigger.tsx @@ -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`} > - )} -
+
+
+
+ {!bare && ( + + )} {titleText} {numberPreview && !isSelfBilled && ( - - ({numberPreview}) + + {numberPreview} )} - {!bare &&

{subtitleText}

}
+ + {isCopyMode && copyInitial && ( +
+ +

+ {t('copy_notice', { number: copyInitial.source_invoice_number })} +

+
+ )} + + {hasBankDetails === false && !isSelfBilled && ( +
+ +

{t('bank_missing_warning')}

+ +
+ )}
- {isCopyMode && copyInitial && ( -
- -

- {t('copy_notice', { number: copyInitial.source_invoice_number })} -

-
- )} - - {!isEditMode && !isCopyMode && ( - setMode(v as 'invoice' | 'self_billed')}> - - {t('mode_invoice')} - {t('mode_self_billed')} - - - )} - - {hasBankDetails === false && !isSelfBilled && ( -
- -

{t('bank_missing_warning')}

- -
- )} - -
-
- {/* Main content */} -
- {/* Customer selection */} - - - {isSelfBilled ? <>{ts('customer_label')} : <>{t('customer_card_title')}} - {isSelfBilled && {ts('issuer_card_description')}} - - - ( - - )} - /> - - {errors.customer_id && ( -

{errors.customer_id.message}

+ +
+ {/* ===== Kund ===== */} +
+ + {isSelfBilled ? ts('customer_label') : t('customer_card_title')} + + {selectedCustomer && ( + + ✓ {t('customer_done')} + )} + + {isSelfBilled && ( +

{ts('issuer_card_description')}

+ )} + ( + + )} + /> + {selectedCustomer && ( +
+ {[ + [selectedCustomer.address_line1, selectedCustomer.postal_code, selectedCustomer.city] + .filter(Boolean) + .join(', '), + selectedCustomer.org_number ? `Org.nr ${selectedCustomer.org_number}` : '', + selectedCustomer.email ?? '', + ] + .filter(Boolean) + .map((line) => ( +
{line}
+ ))} +
+ )} + {errors.customer_id && ( +

{errors.customer_id.message}

+ )} + - {isSelfBilled && ( -
-
- - - {errors.external_invoice_number && ( -

{errors.external_invoice_number.message}

- )} -
-
- - -
+ {isSelfBilled && ( +
+
+ + + {errors.external_invoice_number && ( +

{errors.external_invoice_number.message}

+ )}
+
+ + +
+
+ )} +
+ + {/* ===== Fakturarader ===== */} +
+ + {t('items_card_title')} + + {productRowCount > 0 && ( + + {t('rows_count', { count: productRowCount })} + )} + +
+
+
+ {/* Header row: offset by the drag-grip gutter (w-8). */} +
+
+
{t('description_label')}
+
{t('quantity_label')}
+
{t('unit_price_label')}
+ {vatRegistered &&
{t('vat_label')}
} +
{t('amount_label')}
+
+
+
- - - - {/* Invoice items */} - - - {t('items_card_title')} - - -
- - {fields.map((field, index) => { - const isTextRow = watchItems[index]?.line_type === 'text' - const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0) - const lineVat = vatRegistered && !isTextRow - ? Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100 - : 0 - // Free-text / blank row: just a description field (may be left - // empty for a spacer) and a delete button. - if (isTextRow) { - return ( - -
-
-
- - -
- -
-
-
- ) - } - // Per-row action button. On real invoices it's a ⋮ menu that - // holds both the ROT/RUT skattereduktion choice and delete; - // proformas/delivery notes have no deduction model, so they - // keep a plain trash button (a one-item menu would be noise). - const renderRowActions = (triggerClassName: string) => - isInvoiceDoc ? ( - - - - - - {t('deduction_menu_label')} - { - const next = v === 'none' ? null : (v as 'rot' | 'rut') - setValue(`items.${index}.deduction_type`, next, { shouldDirty: true }) - // The arbetstyp lists are per kind: a ROT code - // must not survive a switch to RUT (the select - // would show it as empty while the payload kept - // the wrong code). - if ( - next !== null && - deductionTypeForWorkType(watchItems[index]?.work_type) !== next - ) { - setValue(`items.${index}.work_type`, null) - } - if (next === null) { - setValue(`items.${index}.work_type`, null) - setValue(`items.${index}.labor_hours`, null) - setValue(`items.${index}.housing_designation`, null) - setValue(`items.${index}.apartment_number`, null) - } else if (watchItems[index]?.accrual_balance_account != null) { - // ROT/RUT och periodisering kombineras aldrig - // på samma rad: avdraget vinner. - setValue(`items.${index}.accrual_period_start`, null) - setValue(`items.${index}.accrual_period_end`, null) - setValue(`items.${index}.accrual_balance_account`, null) - } - }} - > - {t('deduction_none')} - {t('deduction_rot')} - {t('deduction_rut')} - - {canUseAccrual && !watchItems[index]?.deduction_type && ( - <> - - toggleAccrual(index)} className="py-2"> - - {watchItems[index]?.accrual_balance_account != null - ? ta('row_menu_remove') - : ta('row_menu_add')} - - - )} - {watchItems[index]?.line_type !== 'text' && ( - <> - - toggleAccountOverride(index)} className="py-2"> - - {(accountOverrideRows.has(index) || watchItems[index]?.revenue_account) - ? t('row_menu_remove_account') - : t('row_menu_set_account')} - - - )} - {dimensionsEnabled && watchItems[index]?.line_type !== 'text' && ( - <> - - toggleItemDimensions(index)} className="py-2"> - - {(dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions)) - ? t('row_menu_remove_dimensions') - : t('row_menu_set_dimensions')} - - - )} - - + {fields.map((field, index) => { + const item = watchItems[index] + const isTextRow = item?.line_type === 'text' + const rowDescription = item?.description?.trim() + const removeLabel = t('remove_row_aria_named', { + description: rowDescription || t('row_label', { index: index + 1 }), + }) + if (isTextRow) { + return ( + remove(index)} > - - {t('remove_row')} - - - - ) : ( - - ) - return ( - -
- {/* Article picker (artikelregister). Optional: leave on - "Egen rad" to type a free-text line. Selecting an - article pre-fills description, unit, price, VAT and any - revenue-account override. */} -
-
- - ( - applyArticle(index, v)} - freeTextLabel={t('article_free_text')} - placeholder={t('article_placeholder')} - emptyLabel={t('article_search_empty')} - ariaLabel={t('article_label')} - /> - )} - /> -
- {canWrite && ( - - )} -
- - {/* Description + mobile delete button */} -
-
- - - {errors.items?.[index]?.description && ( -

- {errors.items[index].description?.message} -

- )} -
- {renderRowActions('shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden')} -
- - {/* Antal, Enhet, à-pris */} -
-
- - - {errors.items?.[index]?.quantity && ( -

- {errors.items[index].quantity?.message} -

- )} -
-
- - ( - - )} - /> - {errors.items?.[index]?.unit && ( -

- {errors.items[index].unit?.message} -

- )} -
-
- - -
-
- - {/* Moms: hidden entirely when the company is not - momsregistrerad (no VAT may be charged). */} - {vatRegistered && ( -
- - ( - - )} - /> -
- )} - - {/* Desktop row actions (⋮ menu or trash). An invisible - label spacer mirrors the field columns (same Label + - space-y-2), so the button sits on the input row, not - high against the labels, nor low at the row bottom. */} -
- -
- {renderRowActions('')} -
-
- - {/* ROT/RUT-avdrag strip: only when a deduction is active - on this row (chosen via the ⋮ menu). A leading tag shows - which reduction applies; the work-type + hours are - required for the Skatteverket claim. Rows with no - deduction render nothing here and stay clean. */} - {isInvoiceDoc && watchItems[index]?.deduction_type && ( -
-
- - {watchItems[index]?.deduction_type === 'rot' ? 'ROT 30%' : 'RUT 50%'} - - { - const opts = - watchItems[index]?.deduction_type === 'rot' - ? ROT_WORK_TYPES - : RUT_WORK_TYPES - return ( - + + - ) : null - })()} -
- {(errors.items?.[index]?.work_type || errors.items?.[index]?.labor_hours) && ( -

- {errors.items?.[index]?.work_type?.message ?? errors.items?.[index]?.labor_hours?.message} -

- )} - {/* Labor-only disclosure (Skatteverket fakturamodellen). - 30%/50% applies to the full line total: the seller - must ensure the line is 100% labor; material has - to be invoiced separately. */} -
- -

{t('deduction_labor_only_warning')}

-
-
- )} +
+
+ + ) + } - {/* Periodisering (förutbetald intäkt): activated via the - row's ⋮ menu. Intäkten krediteras 29xx vid bokning och - löses upp månadsvis över perioden; momsen påverkas inte. */} - {canUseAccrual && watchItems[index]?.accrual_balance_account != null && ( -
- { - setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true }) - setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true }) - setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true }) - }} - onRemove={() => toggleAccrual(index)} - /> - {errors.items?.[index]?.accrual_period_end && ( -

- {errors.items[index].accrual_period_end?.message} -

- )} -
- )} + const lineTotal = (item?.quantity || 0) * (item?.unit_price || 0) + const rowErrors = errors.items?.[index] + const rowErrorMsg = + rowErrors?.description?.message ?? + rowErrors?.quantity?.message ?? + rowErrors?.unit?.message ?? + (rowErrors?.unit_price ? t('validation_price_invalid') : undefined) + const articleStripOpen = articlePickerRows.has(index) + const accountStripOpen = + isInvoiceDoc && (accountOverrideRows.has(index) || Boolean(item?.revenue_account)) + const dimensionStripOpen = + dimensionsEnabled && + isInvoiceDoc && + (dimensionOverrideRows.has(index) || hasDimensionValues(item?.dimensions)) + const accrualStripOpen = canUseAccrual && item?.accrual_balance_account != null + const showSaveAsArticle = canWrite && !item?.article_id && Boolean(rowDescription) - {/* Optional posting-account override (engångsartikel). When - unset the engine derives the revenue account from the VAT - rate; reverse-charge/export lines ignore the override. */} - {isInvoiceDoc && watchItems[index]?.line_type !== 'text' && - (accountOverrideRows.has(index) || watchItems[index]?.revenue_account) && ( -
-
-
- - ( - field.onChange(v || null)} - /> + return ( + +
+
+ - {errors.items?.[index]?.revenue_account && ( -

- {errors.items[index].revenue_account?.message} -

+
+ + ( + + )} + /> +
+ + {vatRegistered && ( + ( + + )} + /> )} +
+ {formatCurrency(lineTotal, watchCurrency)} +
+ + + + + + + toggleArticlePicker(index)} className="py-2"> + + {t('row_menu_pick_article')} + + {isInvoiceDoc && ( + <> + + {t('deduction_menu_label')} + { + const next = v === 'none' ? null : (v as 'rot' | 'rut') + setValue(`items.${index}.deduction_type`, next, { shouldDirty: true }) + // The arbetstyp lists are per kind: a ROT code + // must not survive a switch to RUT (the select + // would show it as empty while the payload kept + // the wrong code). + if ( + next !== null && + deductionTypeForWorkType(item?.work_type) !== next + ) { + setValue(`items.${index}.work_type`, null) + } + if (next === null) { + setValue(`items.${index}.work_type`, null) + setValue(`items.${index}.labor_hours`, null) + setValue(`items.${index}.housing_designation`, null) + setValue(`items.${index}.apartment_number`, null) + } else if (item?.accrual_balance_account != null) { + // ROT/RUT och periodisering kombineras aldrig + // på samma rad: avdraget vinner. + setValue(`items.${index}.accrual_period_start`, null) + setValue(`items.${index}.accrual_period_end`, null) + setValue(`items.${index}.accrual_balance_account`, null) + } + }} + > + {t('deduction_none')} + {t('deduction_rot')} + {t('deduction_rut')} + + + )} + {canUseAccrual && !item?.deduction_type && ( + <> + + toggleAccrual(index)} className="py-2"> + + {item?.accrual_balance_account != null + ? ta('row_menu_remove') + : ta('row_menu_add')} + + + )} + {isInvoiceDoc && ( + <> + + toggleAccountOverride(index)} className="py-2"> + + {(accountOverrideRows.has(index) || item?.revenue_account) + ? t('row_menu_remove_account') + : t('row_menu_set_account')} + + + )} + {dimensionsEnabled && isInvoiceDoc && ( + <> + + toggleItemDimensions(index)} className="py-2"> + + {(dimensionOverrideRows.has(index) || hasDimensionValues(item?.dimensions)) + ? t('row_menu_remove_dimensions') + : t('row_menu_set_dimensions')} + + + )} + + + +
+ + {rowErrorMsg &&

{rowErrorMsg}

} + + {showSaveAsArticle && ( +
+ +
+ )} + + {/* Article re-link strip (row ⋮ menu): article + selection stays reachable on every committed + row; "Egen rad" detaches the link. */} + {articleStripOpen && ( +
+ + ( + { + applyArticle(index, v) + toggleArticlePicker(index) + }} + freeTextLabel={t('article_free_text')} + placeholder={t('article_placeholder')} + emptyLabel={t('article_search_empty')} + ariaLabel={t('article_label')} + /> + )} + /> +
+ )} + + {/* ROT/RUT-avdrag strip: only when a deduction is + active on this row (chosen via the ⋮ menu). */} + {isInvoiceDoc && item?.deduction_type && ( +
+
+ + {item?.deduction_type === 'rot' ? 'ROT 30%' : 'RUT 50%'} + + { + const opts = + item?.deduction_type === 'rot' ? ROT_WORK_TYPES : RUT_WORK_TYPES + return ( + + ) + }} + /> + { + if (v === '' || v == null) return null + const n = Number(v) + return Number.isFinite(n) ? n : null + }, + })} + /> + {(() => { + const amt = computeDeduction({ + unit_price: item?.unit_price || 0, + quantity: item?.quantity || 0, + deduction_type: item?.deduction_type, + vat_rate: vatRegistered + ? (item?.vat_rate ?? (vatRules?.rate || 25)) + : 0, + }) + return amt > 0 ? ( + + −{formatCurrency(amt, watchCurrency)} + + ) : null + })()} +
+ {(errors.items?.[index]?.work_type || errors.items?.[index]?.labor_hours) && ( +

+ {errors.items?.[index]?.work_type?.message ?? errors.items?.[index]?.labor_hours?.message} +

+ )} + {/* Labor-only disclosure (Skatteverket + fakturamodellen), muted: the page's single + ochre line is the next-step line. */} +
+ +

{t('deduction_labor_only_warning')}

+
+
+ )} + + {/* Periodisering (förutbetald intäkt): activated via + the row's ⋮ menu. */} + {accrualStripOpen && ( +
+ { + setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true }) + setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true }) + setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true }) + }} + onRemove={() => toggleAccrual(index)} + /> + {errors.items?.[index]?.accrual_period_end && ( +

+ {errors.items[index].accrual_period_end?.message} +

+ )} +
+ )} + + {/* Optional posting-account override (engångsartikel). */} + {accountStripOpen && ( +
+
+ + ( + accountField.onChange(v || null)} + /> + )} + /> + {errors.items?.[index]?.revenue_account && ( +

+ {errors.items[index].revenue_account?.message} +

+ )} +
+

{t('revenue_account_hint')}

+
+ )} + + {/* Per-item dimensions override (row ⋮ menu). */} + {dimensionStripOpen && ( +
+
+ updateItemDimension(index, dimNo, code)} + inputClassName="h-8" + /> +
+ {hasDimensionValues(defaultDims) && ( +

+ {t('row_dimensions_inherit_hint', { dims: compactDims(defaultDims) })} +

+ )} +
+ )}
-

{t('revenue_account_hint')}

+
+ ) + })} + + + {/* Unified entry row: never part of the field array until + committed (sort_order and the index-keyed override sets + stay stable). Ghost cells preview the append defaults. */} +
+ - {/* Per-item dimensions override (dimensions PR7): opened - via the row's ⋮ menu. The bag is stored as-is; the - server merges it over the invoice's default_dimensions - for this item's revenue line at booking time. */} - {dimensionsEnabled && isInvoiceDoc && watchItems[index]?.line_type !== 'text' && - (dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions)) && ( -
-
- updateItemDimension(index, dimNo, code)} - inputClassName="h-8" - /> -
- {hasDimensionValues(defaultDims) && ( + {/* Suggestion popover: anchored below the whole table wrap so it + never clips inside the horizontal scroll container. */} + {entryOpen && ( +
+ {/* The hint is a sibling of the listbox (listbox children + must be options); the input references it via + aria-describedby. */} +
+ {entryMatches.map((a, i) => ( + + ))} +
+
+ {entryMatches.length > 0 ? t('entry_hint_matches') : t('entry_hint_free')} +
+
+ )} +
+ + {itemsRootMsg &&

{itemsRootMsg}

} + + {!isSelfBilled && ( + + )} + + {/* Taxed-where-performed disclosure, muted: the page's single + ochre line is the next-step line (design decision d). */} + {showTaxedWherePerformedHint && ( +

+ {t('vat_taxed_where_performed_hint')} +

+ )} +
+ + {/* ===== ROT/RUT claim info ===== */} + {isInvoiceDoc && hasAnyDeduction && ( +
+ {t('deduction_card_title')} +

{t('deduction_card_description')}

+
+
+ + +

+ {/* Stored pn exists only as ciphertext: an empty field on + edit keeps it server-side instead of failing validation. + Otherwise, a kundkort with a personnummer covers an + empty field via the server-side fallback. */} + {initial?.deduction_personnummer_last4 + ? t('deduction_personnummer_kept_hint', { last4: initial.deduction_personnummer_last4 }) + : customerHasPersonalNumber + ? t('deduction_personnummer_customer_hint') + : t('deduction_personnummer_hint')} +

+
+ {hasAnyRotLine && ( +
+ + +

{t('deduction_housing_hint')}

+
+ )} + {capWarnings.length > 0 && ( +
+ {capWarnings.map((w) => ( +

{w}

+ ))} +
+ )} +
+
+ )} + + {/* ===== Förval ===== */} +
+ {t('section_forval')} +
+ {chipTexts.join(' · ')} + + +
+
+
+
+ {!isSelfBilled && ( +
+ + ( + + )} + /> +
+ )} + +
+ + ( + + )} + /> +
+ +
+ +
+ + {errors.invoice_date && ( +

{errors.invoice_date.message}

+ )} +
+
+ +
+ +
+ + {errors.due_date && ( +

{errors.due_date.message}

+ )} +
+
+ + {isSelfBilled && ( +
+ +
+ + {errors.received_date && ( +

{errors.received_date.message}

+ )} +
+
+ )} + + {watchDocumentType === 'invoice' && !isSelfBilled && ( +
+ + +
+ )} + + {!isSelfBilled && ( + <> +
+ +
+ ( + + )} + /> +
+
+
+ +
+ ( + + )} + /> +
+
+ + {/* Online payment link: manual paste or the Stripe auto + toggle. Only real invoices; hidden unless the company + opted in, except when the draft already carries a link. */} + {watchDocumentType === 'invoice' && (paymentLinksEnabled || hasExistingPaymentLink) && ( +
+ + + {errors.payment_link_url ? ( +

{errors.payment_link_url.message}

+ ) : (

- {t('row_dimensions_inherit_hint', { dims: compactDims(defaultDims) })} + {stripeConnected ? t('payment_link_hint_auto') : t('payment_link_hint')}

)} + {stripeConnected && !watchPaymentLinkUrl?.trim() && ( +
+ setValue('payment_link_auto', v, { shouldDirty: true })} + /> + +
+ )}
)} - {/* Mobile summary row */} -
- {t('row_label', { index: index + 1 })} - {formatCurrency(lineTotal + lineVat, watchCurrency)} -
-
- - ) - })} - - -
- - {/* Free-text / blank row: explanatory text under an item, or - an empty spacer. Carries no amounts and never books. Not - offered for a received självfaktura: that is a faithful - revenue-only transcription, and the self-billed endpoint - (SelfBillingInvoiceItemSchema) has no line_type and rejects - zero-amount rows. */} - {!isSelfBilled && ( - - )} -
- - {/* Attention is one ochre sentence, not a banner (UI convention - 6). Silent for the normal 0% case; renders only when a - Swedish rate is actually picked for a customer whose default - is 0%, where it is lawful for taxed-where-performed supplies - only. */} - {showTaxedWherePerformedHint && ( - {t('vat_taxed_where_performed_hint')} - )} -
- - - - {/* ROT/RUT-avdrag claim info. Surfaces only when any item has - a deduction_type set: keeps the form quiet for the 90%+ - of users who don't sell ROT/RUT-eligible services. */} - {isInvoiceDoc && hasAnyDeduction && ( - - - {t('deduction_card_title')} - {t('deduction_card_description')} - - -
- - -

- {/* Stored pn exists only as ciphertext: an empty field on - edit keeps it server-side instead of failing validation. - Otherwise, a kundkort with a personnummer covers an - empty field via the server-side fallback. */} - {initial?.deduction_personnummer_last4 - ? t('deduction_personnummer_kept_hint', { last4: initial.deduction_personnummer_last4 }) - : customerHasPersonalNumber - ? t('deduction_personnummer_customer_hint') - : t('deduction_personnummer_hint')} -

-
- {hasAnyRotLine && ( -
- - -

- {t('deduction_housing_hint')} -

-
- )} - {capWarnings.length > 0 && ( -
- {capWarnings.map((w) => ( -

{w}

- ))} -
- )} -
-
- )} - - {/* Notes */} - - - {t('notes_card_title')} - - -