fix(ui): kill horizontal overflow in dialogs and cut the worst modal copy (#1732)

* fix(dialogs): kill horizontal overflow in dialogs and cut the worst modal copy

Overflow hardening:
- DialogTitle/DialogDescription and SheetTitle/SheetDescription get
  break-words at the primitive, so long unbroken interpolated strings
  (emails, product names, org numbers) can no longer widen any dialog.
- AccountCombobox's non-flat dropdown is portaled to document.body with
  viewport-clamped geometry (new pure helper account-combobox-position.ts,
  unit-tested), the same fix info-tooltip.tsx applies to TooltipContent:
  the 34rem panel inside a scrollable DialogContent was the root cause of
  sideways-scrolling dialogs. Outside-click checks the portaled node,
  position tracks scroll/resize (capture phase), wheel/touchmove stop at
  the panel so react-remove-scroll's modal lock cannot block its scrolling,
  and DialogContent/SheetContent treat data-dialog-companion nodes as
  inside interactions so clicking the panel never dismisses the dialog.
  The flat variant is unchanged.
- StrikeLinesDialog/CorrectionEntryDialog line rows switch bare 1fr grid
  tracks to minmax(0,1fr) and wrap the sm:contents-promoted AccountCombobox
  in a min-w-0 cell (SendInvoiceDialog's pattern).
- New dialog-overflow-risk ratchet in no-new-antipatterns.mjs: bare fr
  tracks in dialog hosts, whitespace-nowrap inside DialogContent regions
  outside an allowlist, and unportaled >=20rem overlays; baselined at the
  post-fix 7 files.

Copy reduction (convention 7, MatchVoucherDialog precedent):
- New shared RattelseExplainer (HelpPopover) carries the "a posted
  verifikat cannot be edited directly" framing once; CorrectionEntryDialog,
  StrikeLinesDialog, RecordateEntryDialog and CorrectMetadataDialog drop
  their permanent inline explainer boxes and keep at most one sentence
  inline (hardcoded Swedish: verifikat surface).
- SendInvoiceDialog keeps the actual addresses inline and moves the fixed
  CC/BCC framing plus the extra-address rules behind a HelpPopover
  (recipient_additional_hint replaced by recipient_help_fixed and
  recipient_help_additional in both messages files).
- HelpPopover panels gain pointer-events-auto and the companion marker so
  they are actually interactive inside modal dialogs.

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

* fix(bookkeeping): mechanism-accurate rattelse copy and calmer dropdown repositioning

The shared RattelseExplainer claimed every rattelse is logged with who/when
in the verifikat's rattelsehistorik, which is only true for the inline
strike-and-replace track (StrikeLinesDialog, CorrectMetadataDialog). The
storno dialogs (CorrectionEntryDialog, RecordateEntryDialog) never write
that log: their BFL 5 kap 5 trail is the storno chain. The shared component
now keeps only the universally true framing sentence, and each dialog's
popover carries the trail sentence matching its own mechanism.

AccountCombobox's capture-phase scroll/resize handler now skips setState
when the recomputed position is shallow-equal to the current one
(isSameDropdownPosition in the pure position helper, unit-tested) and
ignores scroll events originating inside the portaled panel itself, so
scrolling the account list no longer churns re-renders.

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-20 10:03:52 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 68ca152127
commit 834cc4d0e8
17 changed files with 799 additions and 169 deletions
+1
View File
@@ -1085,3 +1085,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-19] Bank reconciliation: ignored transactions are excluded from bank_transaction_total/difference (surfaced as separate count+sum) rather than keeping the old "what the bank moved" semantics: the ignore flag's dominant real-world use is feed duplicates (which never moved money and never get a ledger leg), so including them made is_reconciled unreachable after a correct dupe cleanup (observed: permanent 78 867 kr differens on a fully booked EF account). descriptionsBridge now strips ALL whitespace before the prefix compare (collapse-only still misses a dropped space); safe because char-filtering preserves prefix relations and the compare stays inside a (date,ore) bucket.
[2026-08-19] Inline rättelse bank guard anchors to the linked bank amount, per account, not to the pre-state and not to the 19xx group net: a non-zero change on a 19xx/cash-ledger account is allowed iff the post-state net on that account equals the signed sum of the linked transactions resolved to it (once per transaction, split links by allocated_amount; NULL cash_account_id resolves to the primary cash account, then 1930). Per account rather than group so a wrong-bank-account booking (1930 vs 1940) stays a storno job: a group check would let the net drift between accounts and break per-account bank reconciliation. When no anchor resolves the old strict refusal stands. Reskontra sides (15xx/24xx) stay strictly net-preserving because their anchor is the payment row, not a bank amount.
[2026-08-19] Import mapping step gets a bulk "Bekräfta alla föreslagna" for the VAT-treatment review gate, batching the per-row confirm semantics unchanged (defaults kept, rows marked reviewed): a Fortnox chart routinely puts 70+ class 3/4 accounts behind the gate and the one-click-per-row flow across 50-row pages was an observed live migration dead end (Boltonshield 2026-08-18, stuck at "50 kvar"). Rejected: auto-skipping review for accounts unused by the imported vouchers, because the chart rows are still created with the suggested treatment and a silently wrong default on a soon-used account is exactly what the review gate exists to catch.
[2026-08-19] Dialog overflow hardening: Dialog/Sheet titles and descriptions get break-words at the primitive; AccountCombobox's non-flat dropdown is portaled to document.body with viewport-clamped geometry (same rationale as info-tooltip's TooltipContent portal, since DialogContent's overflow-y-auto otherwise grows a horizontal scrollbar around the 34rem panel); the four rattelse-family dialog explainers are unified behind one RattelseExplainer HelpPopover (convention 7, MatchVoucherDialog precedent) instead of four near-duplicate inline paragraphs; a dialog-overflow-risk ratchet in no-new-antipatterns.mjs keeps bare-1fr tracks, dialog whitespace-nowrap and unportaled wide overlays from coming back.
+207 -74
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useRef, useEffect, useMemo, useCallback, useId } from 'react'
import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback, useId } from 'react'
import { createPortal } from 'react-dom'
import { Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
@@ -11,8 +12,19 @@ import {
type SearchableAccount,
type AccountSearchItem,
} from '@/lib/bookkeeping/account-search'
import {
computeDropdownPosition,
isSameDropdownPosition,
type DropdownPosition,
} from '@/components/bookkeeping/account-combobox-position'
import type { BASAccount } from '@/types'
// Shared by every portaled panel instance: only stops propagation so the
// browser's default scrolling still runs on the panel itself.
function stopScrollPropagation(e: Event) {
e.stopPropagation()
}
interface AccountComboboxProps {
value: string
accounts: BASAccount[]
@@ -59,7 +71,11 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
const [highlightedIndex, setHighlightedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const internalInputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement | null>(null)
// The portaled (non-flat) dropdown panel. It is not a DOM descendant of
// containerRef, so outside-click detection must check it separately.
const portalPanelRef = useRef<HTMLDivElement | null>(null)
const [dropdownPos, setDropdownPos] = useState<DropdownPosition | null>(null)
const selectedNameId = useId()
// Whether the user has typed or arrow-navigated since the field was focused.
// Enter only selects the highlighted item after an actual interaction: a
@@ -129,10 +145,74 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
}
}, [highlightedIndex, isOpen])
// Keep the portaled (non-flat) dropdown glued to the trigger: measure off
// containerRef when it opens, and re-measure while anything scrolls or the
// window resizes underneath it. The capture-phase scroll listener also
// catches scrolling ancestors such as DialogContent's overflow-y-auto body.
const updateDropdownPosition = useCallback(() => {
if (flat || !containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const next = computeDropdownPosition(
{ top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width },
{ width: window.innerWidth, height: window.innerHeight },
)
// Bail out when nothing moved (e.g. scroll ticks that did not shift the
// anchor): returning the previous reference lets React skip the re-render.
setDropdownPos((prev) => (isSameDropdownPosition(prev, next) ? prev : next))
}, [flat])
useLayoutEffect(() => {
if (!isOpen || flat) return
updateDropdownPosition()
const handleScroll = (e: Event) => {
// Scrolling the portaled panel's own list never moves the anchor (the
// panel is position: fixed): repositioning on it would just churn state
// while the user scrolls the account list.
if (e.target instanceof Node && portalPanelRef.current?.contains(e.target)) return
updateDropdownPosition()
}
window.addEventListener('scroll', handleScroll, true)
window.addEventListener('resize', updateDropdownPosition)
return () => {
window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('resize', updateDropdownPosition)
}
}, [isOpen, flat, updateDropdownPosition])
// react-remove-scroll (active inside every modal dialog) preventDefaults
// wheel/touchmove events that reach document from outside the dialog's DOM
// tree, and the portaled panel lives outside that tree. Stopping the events
// at the panel lets the browser scroll it natively; overscroll-contain on
// the panel stops chained page scrolling at the list's edges.
const attachPortalPanel = useCallback((el: HTMLDivElement | null) => {
const prev = portalPanelRef.current
if (prev) {
prev.removeEventListener('wheel', stopScrollPropagation)
prev.removeEventListener('touchmove', stopScrollPropagation)
}
portalPanelRef.current = el
if (el) {
el.addEventListener('wheel', stopScrollPropagation)
el.addEventListener('touchmove', stopScrollPropagation)
}
}, [])
const attachPortalListPanel = useCallback((el: HTMLDivElement | null) => {
listRef.current = el
attachPortalPanel(el)
}, [attachPortalPanel])
// Close dropdown when clicking/tapping outside
useEffect(() => {
function handleClickOutside(e: MouseEvent | TouchEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
const target = e.target as Node
// The non-flat dropdown is portaled to document.body, so it is not a
// DOM descendant of containerRef: check the portaled panel too.
if (
containerRef.current &&
!containerRef.current.contains(target) &&
!(portalPanelRef.current && portalPanelRef.current.contains(target))
) {
setIsOpen(false)
}
}
@@ -260,9 +340,96 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
const showSelectedName = Boolean(selectedName && value && search === value)
// In flat mode the dropdown follows the trigger's width instead of forcing
// 34rem: inside a SettingsRow that fixed width would overflow the dialog.
const listWidthClass = flat ? 'w-full min-w-0' : 'min-w-[24rem] w-[max(100%,34rem)]'
// Flat mode keeps the dropdown as an absolute child that follows the
// trigger's width: inside a SettingsRow a fixed width would overflow the
// dialog. The non-flat dropdown is portaled to document.body with an
// explicit viewport-clamped geometry (computeDropdownPosition) instead, so
// a scrollable DialogContent can never clip it or grow a horizontal
// scrollbar around it (the fix info-tooltip.tsx already applies to
// TooltipContent, extended to this dropdown).
const flatListWidthClass = 'w-full min-w-0'
const portalPanelStyle: React.CSSProperties | undefined = dropdownPos
? {
left: dropdownPos.left,
width: dropdownPos.width,
maxHeight: dropdownPos.maxHeight,
...(dropdownPos.top !== undefined
? { top: dropdownPos.top }
: { bottom: dropdownPos.bottom }),
}
: undefined
// data-dialog-companion: DialogContent/SheetContent treat a pointerdown
// inside a node carrying this attribute as an inside interaction, so
// clicking the portaled panel never dismisses the dialog hosting it.
const listPanelContent = groupedAccounts.map((group) => (
<div key={group.className}>
<div className="sticky top-0 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted border-b border-input">
{group.className}
</div>
{group.accounts.map((item) => {
const flatIndex = flatList.indexOf(item)
const isHighlighted = flatIndex === highlightedIndex
return (
<button
key={item.account_number}
type="button"
data-highlighted={isHighlighted}
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
}`}
onMouseDown={(e) => {
e.preventDefault()
selectAccount(item.account_number)
}}
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<span className={`font-mono shrink-0 ${item.isActive ? '' : 'text-muted-foreground'}`}>
{item.account_number}
</span>
<span className="flex-1 min-w-0 break-words">{item.account_name}</span>
{!item.isActive && (
<span className="shrink-0 self-center text-[11px] text-muted-foreground whitespace-nowrap">
{notActivatedLabel}
</span>
)}
</button>
)
})}
</div>
))
const emptyPanelContent = (
<>
<p className="text-sm text-muted-foreground">
Hittade inget konto som matchar.
</p>
{/^\d{4}$/.test(search.trim()) ? (
<p className="text-xs text-muted-foreground mt-1">
Om det är ett giltigt BAS-konto aktiveras det när du bokför.
</p>
) : (
<p className="text-xs text-muted-foreground mt-1">
Kontot kan behöva aktiveras i din kontoplan.
</p>
)}
{onCreateAccount && (
<button
type="button"
className="mt-2 flex w-full items-center gap-2 rounded-sm border border-input bg-card px-2 py-1.5 text-left text-sm hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault()
setIsOpen(false)
onCreateAccount(search.trim())
}}
>
<Plus className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Skapa konto &quot;{search.trim()}&quot;</span>
</button>
)}
</>
)
const triggerProps = {
ref: setInputRef,
@@ -297,89 +464,55 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
) : null}
{/* Dropdown */}
{isOpen && !disabled && flatList.length > 0 && (
{isOpen && !disabled && flatList.length > 0 && (flat ? (
<div
ref={listRef}
className={cn(
'absolute z-50 top-full left-0 mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-input bg-card shadow-md',
listWidthClass,
flatListWidthClass,
)}
>
{groupedAccounts.map((group) => (
<div key={group.className}>
<div className="sticky top-0 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted border-b border-input">
{group.className}
</div>
{group.accounts.map((item) => {
const flatIndex = flatList.indexOf(item)
const isHighlighted = flatIndex === highlightedIndex
return (
<button
key={item.account_number}
type="button"
data-highlighted={isHighlighted}
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
}`}
onMouseDown={(e) => {
e.preventDefault()
selectAccount(item.account_number)
}}
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<span className={`font-mono shrink-0 ${item.isActive ? '' : 'text-muted-foreground'}`}>
{item.account_number}
</span>
<span className="flex-1 min-w-0 break-words">{item.account_name}</span>
{!item.isActive && (
<span className="shrink-0 self-center text-[11px] text-muted-foreground whitespace-nowrap">
{notActivatedLabel}
</span>
)}
</button>
)
})}
</div>
))}
{listPanelContent}
</div>
)}
) : (
dropdownPos &&
createPortal(
<div
ref={attachPortalListPanel}
data-dialog-companion=""
className="fixed z-50 overflow-y-auto overscroll-contain pointer-events-auto rounded-lg border border-input bg-card shadow-md"
style={portalPanelStyle}
>
{listPanelContent}
</div>,
document.body,
)
))}
{/* Empty state */}
{isOpen && !disabled && search.trim() && flatList.length === 0 && (
{isOpen && !disabled && search.trim() && flatList.length === 0 && (flat ? (
<div
className={cn(
'absolute z-50 top-full left-0 mt-1 rounded-lg border border-input bg-card shadow-md p-3',
listWidthClass,
flatListWidthClass,
)}
>
<p className="text-sm text-muted-foreground">
Hittade inget konto som matchar.
</p>
{/^\d{4}$/.test(search.trim()) ? (
<p className="text-xs text-muted-foreground mt-1">
Om det är ett giltigt BAS-konto aktiveras det när du bokför.
</p>
) : (
<p className="text-xs text-muted-foreground mt-1">
Kontot kan behöva aktiveras i din kontoplan.
</p>
)}
{onCreateAccount && (
<button
type="button"
className="mt-2 flex w-full items-center gap-2 rounded-sm border border-input bg-card px-2 py-1.5 text-left text-sm hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault()
setIsOpen(false)
onCreateAccount(search.trim())
}}
>
<Plus className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Skapa konto &quot;{search.trim()}&quot;</span>
</button>
)}
{emptyPanelContent}
</div>
)}
) : (
dropdownPos &&
createPortal(
<div
ref={attachPortalPanel}
data-dialog-companion=""
className="fixed z-50 overflow-y-auto overscroll-contain pointer-events-auto rounded-lg border border-input bg-card p-3 shadow-md"
style={portalPanelStyle}
>
{emptyPanelContent}
</div>,
document.body,
)
))}
</div>
)
}
@@ -6,9 +6,11 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
@@ -88,19 +90,31 @@ export default function CorrectMetadataDialog({ entry, open, onOpenChange, onCor
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ändra text eller datum</DialogTitle>
{/* Convention 7: the how-it-works copy lives behind the "?", not in
the dialog flow. */}
<div className="flex items-center gap-2">
<DialogTitle>Ändra text eller datum</DialogTitle>
<RattelseExplainer>
<p>
Verifikationstexten och datumet kan rättas här utan
ändringsverifikation.
</p>
<p>
Varje rättelse loggas med vem och när, och det ursprungliga
innehållet förblir synligt i verifikatets rättelsehistorik.
</p>
<p>
Om månaden redan är momsdeklarerad kan en datumflytt påverka
den inlämnade deklarationen.
</p>
</RattelseExplainer>
</div>
<DialogDescription>
Datumet kan bara flyttas inom samma bokföringsperiod: använd
&quot;Flytta till annat datum&quot; för att byta period.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
<p>
Verifikationstexten och datumet kan rättas utan ändringsverifikation. Rättelsen loggas
med vem och när, och det gamla värdet förblir synligt i rättelsehistoriken. Datumet kan
bara flyttas inom samma bokföringsperiod: använd &quot;Flytta till annat datum&quot; för att
byta period. Om månaden redan är momsdeklarerad kan en datumflytt påverka den inlämnade
deklarationen.
</p>
</div>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="rattelse-description">Verifikationstext</Label>
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import CorrectionPreview from '@/components/bookkeeping/CorrectionPreview'
import {
@@ -264,22 +265,31 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Skapa ändringsverifikation</DialogTitle>
{/* Convention 7: the how-it-works copy lives behind the "?", not in
the dialog flow. */}
<div className="flex items-center gap-2">
<DialogTitle>Skapa ändringsverifikation</DialogTitle>
<RattelseExplainer>
<p>
Här skapas automatiskt en stornoverifikation som nollställer
originalet och en ny verifikation med dina rättade uppgifter.
Rättelsen bokförs i samma räkenskapsperiod som originalet: du
hittar den under originalets räkenskapsår.
</p>
<p>
Spårbarheten ligger i stornokedjan: originalet,
stornoverifikationen och ändringsverifikationen förblir synliga
i bokföringen och länkade till varandra.
</p>
<p>
Tar du bort ett konto ur de rättade raderna nollställs det
(stornon återför det). Vill du bara återföra hela verifikatet
utan att ersätta det, använd Återför (storno) istället.
</p>
</RattelseExplainer>
</div>
</DialogHeader>
{/* Storno explanation */}
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Hur fungerar en ändringsverifikation?</p>
<p>En bokförd verifikation kan inte ändras direkt. Istället skapas automatiskt:</p>
<ol className="list-decimal list-inside mt-1 space-y-0.5">
<li>En <strong>stornoverifikation</strong> som nollställer den ursprungliga</li>
<li>En ny verifikation med dina rättade uppgifter</li>
</ol>
<p className="mt-2">
Rättelsen bokförs i samma räkenskapsperiod som originalet: du hittar den under originalets räkenskapsår.
</p>
</div>
{/* Original entry metadata: lines live inside CorrectionPreview below */}
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
@@ -318,9 +328,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
<div className="space-y-1">
<p className="text-sm font-medium">Rättade rader</p>
<p className="text-xs text-muted-foreground">
Det här är hela den nya verifikationen: alla konton som ska finnas kvar måste stå
kvar. Tar du bort ett konto nollställs det (stornon återför det). Vill du bara återföra
hela verifikatet utan att ersätta det, använd Återför (storno) istället.
Det här är hela den nya verifikationen: alla konton som ska finnas kvar måste stå kvar.
</p>
</div>
@@ -340,19 +348,25 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
<div className="space-y-2">
{lines.map((line, index) => (
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[1fr_1fr_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
<div className="grid grid-cols-[1fr_auto] sm:contents gap-2">
<AccountCombobox
value={line.account_number}
accounts={activeAccounts}
catalog={selectableCatalog}
onChange={(v) => updateLineAccount(index, v)}
onCreateAccount={(prefill) => {
setCreatingAccountForLine(index)
setCreateAccountPrefill(prefill)
}}
disabled={accountsStatus !== 'ready'}
/>
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
<div className="grid grid-cols-[minmax(0,1fr)_auto] sm:contents gap-2">
{/* min-w-0: at sm: the sm:contents wrapper promotes this cell
to a direct grid item; without it the combobox refuses to
shrink below its content and overflows the dialog (same
pattern as SendInvoiceDialog's desktop rows). */}
<div className="min-w-0">
<AccountCombobox
value={line.account_number}
accounts={activeAccounts}
catalog={selectableCatalog}
onChange={(v) => updateLineAccount(index, v)}
onCreateAccount={(prefill) => {
setCreatingAccountForLine(index)
setCreateAccountPrefill(prefill)
}}
disabled={accountsStatus !== 'ready'}
/>
</div>
<Button
variant="ghost"
size="icon"
@@ -0,0 +1,39 @@
'use client'
import { HelpPopover } from '@/components/ui/help-popover'
interface RattelseExplainerProps {
/** Per-dialog specifics, rendered after the shared framing paragraph. */
children: React.ReactNode
className?: string
}
/**
* Shared "?" help for the rättelse family of dialogs (CorrectionEntryDialog,
* StrikeLinesDialog, RecordateEntryDialog, CorrectMetadataDialog): one place
* for the "a posted verifikat cannot be edited directly" framing, so the four
* dialogs stop maintaining near-duplicate inline paragraphs. Rendered next to
* the DialogTitle per UI-migration convention 7 (help lives behind a "?",
* not in the dialog flow: see MatchVoucherDialog). Stays Swedish
* (verifikat surface, .claude/rules/i18n.md).
*
* Only the universally true framing lives here. The audit-trail sentence is
* mechanism-specific (BFL 5 kap 5 §: two distinct correction tracks) and must
* come from each dialog's children: the inline strike-and-replace dialogs
* (StrikeLinesDialog, CorrectMetadataDialog) write the who/when
* rättelsehistorik log, while the storno dialogs (CorrectionEntryDialog,
* RecordateEntryDialog) never touch that log: their trail is the storno chain
* of linked verifikat. Claiming the rättelsehistorik here would be false for
* the storno paths.
*/
export default function RattelseExplainer({ children, className }: RattelseExplainerProps) {
return (
<HelpPopover className={className}>
<p>
En bokförd verifikation kan inte ändras direkt: enligt bokföringslagen
måste varje rättelse vara spårbar i efterhand.
</p>
<div className="mt-2 space-y-2">{children}</div>
</HelpPopover>
)
}
+18 -14
View File
@@ -13,6 +13,7 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { AlertTriangle, Lock, ArrowRight } from 'lucide-react'
@@ -160,22 +161,25 @@ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMove
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Rätta datum</DialogTitle>
{/* Convention 7: the how-it-works copy lives behind the "?", not in
the dialog flow. */}
<div className="flex items-center gap-2">
<DialogTitle>Rätta datum</DialogTitle>
<RattelseExplainer>
<p>
Raderna behålls oförändrade: en stornoverifikation nollställer
originalet i sin period, och en ny verifikation bokförs med
samma rader på det nya datumet.
</p>
<p>
Spårbarheten ligger i stornokedjan: originalet,
stornoverifikationen och den nya verifikationen förblir synliga
i bokföringen och länkade till varandra.
</p>
</RattelseExplainer>
</div>
</DialogHeader>
{/* Explanation */}
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Flytta verifikationen till rätt datum</p>
<p>
En bokförd verifikation kan inte ändras direkt. Raderna behålls oförändrade: istället
skapas automatiskt:
</p>
<ol className="list-decimal list-inside mt-1 space-y-0.5">
<li>En <strong>stornoverifikation</strong> som nollställer originalet i sin period</li>
<li>En ny verifikation med samma rader, bokförd på det nya datumet</li>
</ol>
</div>
{/* Original */}
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
+44 -24
View File
@@ -7,12 +7,14 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import { AccountNumber } from '@/components/ui/account-number'
import { useToast } from '@/components/ui/use-toast'
@@ -220,19 +222,31 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Stryk rader i verifikatet</DialogTitle>
{/* Convention 7: the how-it-works copy lives behind the "?", not in
the dialog flow. */}
<div className="flex items-center gap-2">
<DialogTitle>Stryk rader i verifikatet</DialogTitle>
<RattelseExplainer>
<p>
Här stryks felaktiga rader och ersätts direkt i samma verifikat,
utan ändringsverifikation. Det fungerar bara i öppna, olåsta
perioder.
</p>
<p>
Varje rättelse loggas med vem och när, och de ursprungliga
raderna förblir synliga i verifikatets rättelsehistorik.
</p>
<p>
Om månaden redan är momsdeklarerad kan en ändring av momskonton
påverka den inlämnade deklarationen.
</p>
</RattelseExplainer>
</div>
<DialogDescription>
De strukna raderna förblir synliga (överstrukna) i verifikatet.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Rättelse i samma verifikat</p>
<p>
Felaktiga rader stryks och ersätts direkt i verifikatet, utan ändringsverifikation.
De strukna raderna förblir synliga (överstrukna) och rättelsen loggas med vem och när,
enligt bokföringslagen. Fungerar bara i öppna, olåsta perioder. Om månaden redan är
momsdeklarerad kan en ändring av momskonton påverka den inlämnade deklarationen.
</p>
</div>
{/* Original lines with strike checkboxes */}
<div className="space-y-1">
<p className="text-sm font-medium">Markera rader som ska strykas</p>
@@ -295,19 +309,25 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
<div className="space-y-2">
{newLines.map((line, index) => (
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[1fr_1fr_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
<div className="grid grid-cols-[1fr_auto] sm:contents gap-2">
<AccountCombobox
value={line.account_number}
accounts={activeAccounts}
catalog={selectableCatalog}
onChange={(v) => updateNewLineAccount(index, v)}
onCreateAccount={(prefill) => {
setCreatingAccountForLine(index)
setCreateAccountPrefill(prefill)
}}
disabled={accountsStatus !== 'ready'}
/>
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
<div className="grid grid-cols-[minmax(0,1fr)_auto] sm:contents gap-2">
{/* min-w-0: at sm: the sm:contents wrapper promotes this cell
to a direct grid item; without it the combobox refuses to
shrink below its content and overflows the dialog (same
pattern as SendInvoiceDialog's desktop rows). */}
<div className="min-w-0">
<AccountCombobox
value={line.account_number}
accounts={activeAccounts}
catalog={selectableCatalog}
onChange={(v) => updateNewLineAccount(index, v)}
onCreateAccount={(prefill) => {
setCreatingAccountForLine(index)
setCreateAccountPrefill(prefill)
}}
disabled={accountsStatus !== 'ready'}
/>
</div>
<Button
variant="ghost"
size="icon"
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest'
import {
computeDropdownPosition,
isSameDropdownPosition,
DROPDOWN_PREFERRED_WIDTH,
DROPDOWN_VIEWPORT_MARGIN,
DROPDOWN_ANCHOR_GAP,
DROPDOWN_MAX_HEIGHT,
} from '../account-combobox-position'
const desktop = { width: 1440, height: 900 }
describe('computeDropdownPosition', () => {
it('opens below the anchor at the preferred width when there is room', () => {
const pos = computeDropdownPosition(
{ top: 200, bottom: 232, left: 300, width: 160 },
desktop,
)
expect(pos.top).toBe(232 + DROPDOWN_ANCHOR_GAP)
expect(pos.bottom).toBeUndefined()
expect(pos.left).toBe(300)
expect(pos.width).toBe(DROPDOWN_PREFERRED_WIDTH)
expect(pos.maxHeight).toBe(DROPDOWN_MAX_HEIGHT)
})
it('keeps the anchor width when the trigger is wider than the preferred width', () => {
const pos = computeDropdownPosition(
{ top: 200, bottom: 232, left: 100, width: 700 },
desktop,
)
expect(pos.width).toBe(700)
})
it('clamps left so the panel never crosses the right viewport edge', () => {
const pos = computeDropdownPosition(
{ top: 200, bottom: 232, left: 1200, width: 160 },
desktop,
)
expect(pos.left + pos.width).toBe(desktop.width - DROPDOWN_VIEWPORT_MARGIN)
})
it('never puts the panel past the left margin', () => {
const pos = computeDropdownPosition(
{ top: 200, bottom: 232, left: 2, width: 160 },
desktop,
)
expect(pos.left).toBe(DROPDOWN_VIEWPORT_MARGIN)
})
it('shrinks to the viewport on a narrow (mobile) screen', () => {
const mobile = { width: 375, height: 700 }
const pos = computeDropdownPosition(
{ top: 100, bottom: 140, left: 16, width: 200 },
mobile,
)
expect(pos.width).toBe(375 - DROPDOWN_VIEWPORT_MARGIN * 2)
expect(pos.left).toBe(DROPDOWN_VIEWPORT_MARGIN)
})
it('caps maxHeight to the space below the anchor', () => {
const pos = computeDropdownPosition(
{ top: 650, bottom: 682, left: 300, width: 160 },
desktop,
)
expect(pos.top).toBe(682 + DROPDOWN_ANCHOR_GAP)
expect(pos.maxHeight).toBe(
desktop.height - 682 - DROPDOWN_ANCHOR_GAP - DROPDOWN_VIEWPORT_MARGIN,
)
})
it('flips above the anchor when the space below is too small', () => {
const pos = computeDropdownPosition(
{ top: 800, bottom: 832, left: 300, width: 160 },
desktop,
)
expect(pos.top).toBeUndefined()
expect(pos.bottom).toBe(desktop.height - 800 + DROPDOWN_ANCHOR_GAP)
expect(pos.maxHeight).toBe(DROPDOWN_MAX_HEIGHT)
})
it('stays below when the space above is even smaller than below', () => {
const shortViewport = { width: 1440, height: 220 }
const pos = computeDropdownPosition(
{ top: 40, bottom: 72, left: 300, width: 160 },
shortViewport,
)
expect(pos.top).toBe(72 + DROPDOWN_ANCHOR_GAP)
// Cramped viewport: the height floor keeps the list usable and scrollable.
expect(pos.maxHeight).toBeGreaterThanOrEqual(96)
})
})
describe('isSameDropdownPosition', () => {
const anchor = { top: 200, bottom: 232, left: 300, width: 160 }
it('is false against null (no previous position)', () => {
expect(isSameDropdownPosition(null, computeDropdownPosition(anchor, desktop))).toBe(false)
})
it('is true for two computations off an unmoved anchor', () => {
const a = computeDropdownPosition(anchor, desktop)
const b = computeDropdownPosition({ ...anchor }, desktop)
expect(isSameDropdownPosition(a, b)).toBe(true)
})
it.each([
['left', { left: 301 }],
['width', { width: 545 }],
['maxHeight', { maxHeight: 299 }],
['top', { top: 237 }],
] as const)('is false when %s differs', (_field, patch) => {
const a = computeDropdownPosition(anchor, desktop)
expect(isSameDropdownPosition(a, { ...a, ...patch })).toBe(false)
})
it('distinguishes open-below from open-above at the same coordinates', () => {
const below = computeDropdownPosition(anchor, desktop)
const above = computeDropdownPosition({ top: 800, bottom: 832, left: 300, width: 160 }, desktop)
expect(below.top).toBeDefined()
expect(above.bottom).toBeDefined()
expect(isSameDropdownPosition(below, above)).toBe(false)
// Flipping sides toggles which of top/bottom is undefined: the guard must
// compare both, not just the defined one.
expect(
isSameDropdownPosition(below, { ...below, top: undefined, bottom: 123 }),
).toBe(false)
})
})
@@ -0,0 +1,92 @@
/**
* Pure positioning for AccountCombobox's portaled (non-flat) dropdown.
*
* The non-flat dropdown renders on document.body (see AccountCombobox) so a
* scrollable DialogContent can never clip it or grow a horizontal scrollbar
* around it. That trades CSS anchoring (absolute + top-full) for explicit
* viewport math, which lives here so it can be unit-tested headlessly.
*
* All numbers are CSS pixels in viewport coordinates (getBoundingClientRect
* space), matching position: fixed.
*/
export interface DropdownAnchorRect {
top: number
bottom: number
left: number
width: number
}
export interface DropdownViewportSize {
width: number
height: number
}
export interface DropdownPosition {
left: number
width: number
maxHeight: number
/** Set when the dropdown opens below the anchor (CSS `top`). */
top?: number
/**
* Set when the dropdown opens above the anchor (CSS `bottom`, measured from
* the viewport bottom). Anchoring by `bottom` lets the panel grow upward
* without knowing its own height.
*/
bottom?: number
}
/** 34rem: wide enough for account number + name + activation marker. */
export const DROPDOWN_PREFERRED_WIDTH = 544
/** Minimum gap kept between the dropdown and every viewport edge. */
export const DROPDOWN_VIEWPORT_MARGIN = 8
/** Gap between the anchor (trigger) and the dropdown. */
export const DROPDOWN_ANCHOR_GAP = 4
/** The dropdown's own scroll ceiling (was max-h-[300px] pre-portal). */
export const DROPDOWN_MAX_HEIGHT = 300
/** Below this much room the dropdown flips above the anchor instead. */
const MIN_USEFUL_HEIGHT = 120
/** Height floor so a cramped viewport still shows a scrollable list. */
const HEIGHT_FLOOR = 96
/**
* Shallow equality over the full position shape. The scroll/resize reposition
* handler uses this to skip setState when the recomputed geometry is
* unchanged (e.g. scroll events that did not move the anchor), so React does
* not re-render the open dropdown on every scroll tick.
*/
export function isSameDropdownPosition(
a: DropdownPosition | null,
b: DropdownPosition,
): boolean {
return (
a !== null &&
a.left === b.left &&
a.width === b.width &&
a.maxHeight === b.maxHeight &&
a.top === b.top &&
a.bottom === b.bottom
)
}
export function computeDropdownPosition(
anchor: DropdownAnchorRect,
viewport: DropdownViewportSize,
): DropdownPosition {
const margin = DROPDOWN_VIEWPORT_MARGIN
const maxWidth = Math.max(viewport.width - margin * 2, 0)
const width = Math.min(Math.max(anchor.width, DROPDOWN_PREFERRED_WIDTH), maxWidth)
const left = Math.max(margin, Math.min(anchor.left, viewport.width - width - margin))
const spaceBelow = viewport.height - anchor.bottom - DROPDOWN_ANCHOR_GAP - margin
const spaceAbove = anchor.top - DROPDOWN_ANCHOR_GAP - margin
const openUp = spaceBelow < MIN_USEFUL_HEIGHT && spaceAbove > spaceBelow
const available = openUp ? spaceAbove : spaceBelow
const maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, Math.max(available, HEIGHT_FLOOR))
if (openUp) {
return { left, width, maxHeight, bottom: viewport.height - anchor.top + DROPDOWN_ANCHOR_GAP }
}
return { left, width, maxHeight, top: anchor.bottom + DROPDOWN_ANCHOR_GAP }
}
+16 -5
View File
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { HelpPopover } from '@/components/ui/help-popover'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
@@ -497,10 +498,21 @@ export default function SendInvoiceDialog({
{mode === 'email' && (
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="space-y-1 text-sm">
<p>
<span className="font-medium">{t('recipient_to_label')}:</span>{' '}
{invoice.customer.email}
</p>
<div className="flex items-start justify-between gap-2">
<p>
<span className="font-medium">{t('recipient_to_label')}:</span>{' '}
{invoice.customer.email}
</p>
{/* Convention 7: the why of fixed CC/BCC and the extra
address rules live behind the "?": only the actual
addresses stay inline. */}
<HelpPopover>
<p>{t('recipient_help_fixed')}</p>
{canCustomizeRecipients && (
<p className="mt-2">{t('recipient_help_additional')}</p>
)}
</HelpPopover>
</div>
<p className="text-muted-foreground">
<span className="font-medium text-foreground">{t('recipient_fixed_cc_label')}:</span>{' '}
{fixedRecipients.cc.length > 0 ? fixedRecipients.cc.join(', ') : t('recipient_none')}
@@ -536,7 +548,6 @@ export default function SendInvoiceDialog({
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('recipient_additional_hint')}</p>
{recipientError && (
<p className="text-sm text-destructive" role="alert">{recipientError}</p>
)}
+15 -3
View File
@@ -31,11 +31,23 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
>(({ className, children, onInteractOutside, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
// Companion overlays (AccountCombobox's dropdown, HelpPopover panels)
// are portaled to document.body so this content's overflow-y-auto can
// never clip them. DOM-wise that puts them OUTSIDE the dialog, so Radix
// would otherwise dismiss the dialog on a pointerdown inside them:
// anything marked data-dialog-companion counts as inside.
onInteractOutside={(event) => {
onInteractOutside?.(event)
const target = event.target
if (target instanceof Element && target.closest('[data-dialog-companion]')) {
event.preventDefault()
}
}}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98 sm:rounded-xl",
className
@@ -106,7 +118,7 @@ const DialogTitle = React.forwardRef<
ref={ref}
data-ph-unmask=""
className={cn(
"text-lg leading-none tracking-tight",
"break-words text-lg leading-none tracking-tight",
className
)}
{...props}
@@ -121,7 +133,7 @@ const DialogDescription = React.forwardRef<
<DialogPrimitive.Description
ref={ref}
data-ph-unmask=""
className={cn("text-sm text-muted-foreground", className)}
className={cn("break-words text-sm text-muted-foreground", className)}
{...props}
/>
))
+5 -1
View File
@@ -86,9 +86,13 @@ export function HelpPopover({ children, className }: HelpPopoverProps) {
ref={panelRef}
role="note"
data-help-popover=""
// Inside a modal dialog the panel is DOM-outside DialogContent:
// data-dialog-companion keeps a click in it from dismissing the
// dialog, and pointer-events-auto undoes the modal body lock.
data-dialog-companion=""
// data-ph-unmask: page help is static i18n chrome in session replays.
data-ph-unmask=""
className="fixed z-[60] w-[300px] rounded-lg border border-border bg-popover p-4 text-[13px] leading-relaxed text-foreground shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
className="pointer-events-auto fixed z-[60] w-[300px] rounded-lg border border-border bg-popover p-4 text-[13px] leading-relaxed text-foreground shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
style={{ top: pos.top, left: pos.left }}
>
{children}
+12 -3
View File
@@ -49,11 +49,20 @@ interface SheetContentProps
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ className, children, side = "right", ...props }, ref) => (
>(({ className, children, side = "right", onInteractOutside, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
// Same companion-overlay guard as DialogContent: a portaled panel
// marked data-dialog-companion counts as an inside interaction.
onInteractOutside={(event) => {
onInteractOutside?.(event)
const target = event.target
if (target instanceof Element && target.closest('[data-dialog-companion]')) {
event.preventDefault()
}
}}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background p-6 transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
sideClasses[side],
@@ -105,7 +114,7 @@ const SheetTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
data-ph-unmask=""
className={cn("text-base tracking-tight", className)}
className={cn("break-words text-base tracking-tight", className)}
{...props}
/>
))
@@ -118,7 +127,7 @@ const SheetDescription = React.forwardRef<
<DialogPrimitive.Description
ref={ref}
data-ph-unmask=""
className={cn("text-sm text-muted-foreground", className)}
className={cn("break-words text-sm text-muted-foreground", className)}
{...props}
/>
))
+2 -1
View File
@@ -4224,7 +4224,8 @@
"recipient_additional_cc_label": "Additional CC for this invoice",
"recipient_additional_bcc_label": "Additional BCC for this invoice",
"recipient_additional_placeholder": "name@company.com",
"recipient_additional_hint": "Separate multiple addresses with commas or semicolons.",
"recipient_help_fixed": "Fixed CC and BCC addresses are added to every send. They come from the invoice email settings and from the customer card.",
"recipient_help_additional": "Additional addresses apply to this send only. Separate multiple addresses with commas or semicolons.",
"recipient_invalid": "Invalid email address: {address}",
"recipient_too_many": "An invoice email can have at most {count} recipients in total.",
"cancel": "Cancel",
+2 -1
View File
@@ -4224,7 +4224,8 @@
"recipient_additional_cc_label": "Extra CC för denna faktura",
"recipient_additional_bcc_label": "Extra BCC för denna faktura",
"recipient_additional_placeholder": "namn@foretag.se",
"recipient_additional_hint": "Separera flera adresser med komma eller semikolon.",
"recipient_help_fixed": "Fasta CC- och BCC-adresser läggs till på varje utskick. De hämtas från e-postinställningarna för fakturor och från kundkortet.",
"recipient_help_additional": "Extra adresser gäller bara det här utskicket. Separera flera adresser med komma eller semikolon.",
"recipient_invalid": "Ogiltig e-postadress: {address}",
"recipient_too_many": "Ett fakturautskick får ha högst {count} mottagare totalt.",
"cancel": "Avbryt",
+12
View File
@@ -20,5 +20,17 @@
"lib/bokslut/tax-provision/bolagsskatt-calculator.ts",
"lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts"
]
},
"dialogOverflowRisk": {
"count": 7,
"files": [
"app/(dashboard)/supplier-invoices/[id]/page.tsx",
"components/bookkeeping/JournalEntryForm.tsx",
"components/invoices/PaymentBookingDialog.tsx",
"components/invoices/SendInvoiceDialog.tsx",
"components/reports/views/index.tsx",
"components/salary/NewEmployeeDialog.tsx",
"components/transactions/InvoiceMatchDialog.tsx"
]
}
}
+137 -2
View File
@@ -82,6 +82,23 @@
* entitlement paywall live (diagnosed 2026-08-17). Read flags as values
* via lib/env/public-flags. No baseline: the count is 0, any new one is
* a hard failure.
* 11. dialog-overflow-risk: patterns that make a dialog scroll sideways.
* (a) a bare `1fr` grid track inside grid-cols-[...] in a file that
* imports DialogContent/SheetContent: per the CSS Grid spec a bare fr
* track's implicit minimum is auto (its content's min-content size), so
* the track refuses to shrink below its content and overflows the dialog
* (StrikeLinesDialog and CorrectionEntryDialog shipped this, fixed
* 2026-08-19; TransactionBookingDialog had the safe minmax(0,1fr) idiom
* all along). (b) whitespace-nowrap inside a <DialogContent>/
* <SheetContent> JSX region outside DIALOG_NOWRAP_ALLOWED (numeric
* columns inside their own overflow-x-auto wrapper are fine and
* allowlisted per file). (c) a hand-rolled absolute overlay forcing
* min-w-[>=20rem] in a file that never portals anything to
* document.body: DialogContent's overflow-y-auto computes overflow-x to
* auto as well, so an oversized non-portaled panel grows the dialog a
* horizontal scrollbar instead of repositioning (AccountCombobox's
* dropdown pre-2026-08-19). Tracked as a per-file baseline set that may
* only shrink.
*
* Usage:
* node scripts/checks/no-new-antipatterns.mjs # check (CI)
@@ -492,6 +509,85 @@ function findFoldedPublicFlags() {
return [...new Set(findings)].sort()
}
// 11. dialog-overflow-risk. See the header comment for the three patterns.
// Files whose whitespace-nowrap cells are fixed-width numeric/tabular columns
// living inside their OWN overflow-x-auto scroll container, so they cannot
// widen the dialog itself:
// - MockDataImportDialog: CSV preview built on the Table primitive, which
// self-wraps in overflow-auto (components/ui/table.tsx).
// - PaymentFileDialog: payment-line table wrapped in an overflow-x-auto div.
const DIALOG_NOWRAP_ALLOWED = new Set([
'components/extensions/shared/MockDataImportDialog.tsx',
'components/supplier-invoices/PaymentFileDialog.tsx',
])
const DIALOG_CONTENT_IMPORT_RE =
/import\s*\{[^}]*\b(?:DialogContent|SheetContent)\b[^}]*\}\s*from\s*['"]@\/components\/ui\/(?:dialog|sheet)['"]/
const GRID_COLS_TEMPLATE_RE = /grid-cols-\[([^\]]+)\]/g
const BARE_FR_TOKEN_RE = /^\d+(?:\.\d+)?fr$/
const WIDE_MIN_W_RE = /min-w-\[(\d+(?:\.\d+)?)(rem|px)\]/g
const PORTAL_HINT_RE = /createPortal|\bPortal\b/
const OVERLAY_ABSOLUTE_RE = /\babsolute\b/
const OVERLAY_Z_RE = /\bz-(?:40|50|\[\d+\])/
/**
* Overflow-risky patterns in dialog/sheet hosts. Returns
* { file, where, rule } findings; the ratchet compares the file set.
*/
function findDialogOverflowRisks() {
const files = [
...walk(path.join(ROOT, 'app'), ['.tsx']),
...walk(path.join(ROOT, 'components'), ['.tsx']),
...walk(path.join(ROOT, 'extensions'), ['.tsx']),
]
const findings = []
for (const f of files) {
const r = rel(f)
const src = fs.readFileSync(f, 'utf8')
const lines = src.split('\n')
if (DIALOG_CONTENT_IMPORT_RE.test(src)) {
// (a) bare fr grid tracks anywhere in a dialog-hosting file.
lines.forEach((line, i) => {
for (const m of line.matchAll(GRID_COLS_TEMPLATE_RE)) {
if (m[1].split('_').some((token) => BARE_FR_TOKEN_RE.test(token))) {
findings.push({ file: r, where: `${r}:${i + 1}`, rule: 'bare-fr-grid-track' })
}
}
})
// (b) whitespace-nowrap inside the <DialogContent>/<SheetContent>
// region. Line-based depth tracking is a heuristic, but dialog JSX in
// this repo keeps the tags on their own lines.
if (!DIALOG_NOWRAP_ALLOWED.has(r)) {
let depth = 0
lines.forEach((line, i) => {
if (/<(?:Dialog|Sheet)Content\b/.test(line)) depth++
if (depth > 0 && line.includes('whitespace-nowrap')) {
findings.push({ file: r, where: `${r}:${i + 1}`, rule: 'nowrap-in-dialog' })
}
const closes = (line.match(/<\/(?:Dialog|Sheet)Content>/g) || []).length
depth = Math.max(0, depth - closes)
})
}
}
// (c) a hand-rolled absolute overlay forcing a >=20rem minimum width in a
// file that never portals anything: inside a scrollable DialogContent
// that minimum becomes a horizontal scrollbar on the dialog.
if (!PORTAL_HINT_RE.test(src) && OVERLAY_ABSOLUTE_RE.test(src) && OVERLAY_Z_RE.test(src)) {
lines.forEach((line, i) => {
for (const m of line.matchAll(WIDE_MIN_W_RE)) {
const value = parseFloat(m[1])
if ((m[2] === 'rem' && value >= 20) || (m[2] === 'px' && value >= 320)) {
findings.push({ file: r, where: `${r}:${i + 1}`, rule: 'unportaled-wide-overlay' })
}
}
})
}
}
return findings.sort((a, b) => a.where.localeCompare(b.where))
}
// Dependencies pinned to an EXACT version on purpose, because a bump broke prod
// and must not silently return via `npm update`, a dependabot bump, or a manual
// install. Any drift (in package.json OR the lockfile) fails CI. See DECISIONS.md.
@@ -834,8 +930,11 @@ const current = {
extensionRoutes: findExtensionRouteFindings(ROOT),
offLadderRadii: findOffLadderRadii(),
foldedPublicFlags: findFoldedPublicFlags(),
dialogOverflowRisk: findDialogOverflowRisks(),
}
const dialogOverflowFiles = [...new Set(current.dialogOverflowRisk.map((f) => f.file))].sort()
const isUpdate = process.argv.includes('--update')
if (isUpdate) {
@@ -849,6 +948,10 @@ if (isUpdate) {
count: current.ledgerScanningReports.length,
files: current.ledgerScanningReports,
},
dialogOverflowRisk: {
count: dialogOverflowFiles.length,
files: dialogOverflowFiles,
},
}
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n')
console.log(
@@ -1068,6 +1171,31 @@ if (newLedgerScans.length) {
)
}
// 1e3. dialog-overflow-risk: per-file ratchet, a finding in a file outside
// the baseline set is a NEW violation. Grandfathered files stay until fixed.
const dialogOverflowBaseline = new Set(baseline.dialogOverflowRisk?.files ?? [])
const newDialogOverflow = current.dialogOverflowRisk.filter(
(finding) => !dialogOverflowBaseline.has(finding.file),
)
const fixedDialogOverflow = [...dialogOverflowBaseline].filter(
(file) => !dialogOverflowFiles.includes(file),
)
if (newDialogOverflow.length) {
failed = true
console.error(
`\n✗ dialog-overflow-risk: ${newDialogOverflow.length} overflow-risky pattern(s) in new dialog/sheet file(s):`,
)
newDialogOverflow.forEach((finding) => console.error(` ${finding.where} (${finding.rule})`))
console.error(
' → bare-fr-grid-track: a bare 1fr track refuses to shrink below its content; use\n' +
' minmax(0,1fr), plus min-w-0 on the cell when a combobox/long text lives in it.\n' +
' nowrap-in-dialog: give the table/row its own overflow-x-auto wrapper, then allowlist\n' +
' the file in DIALOG_NOWRAP_ALLOWED in this script with a reason.\n' +
' unportaled-wide-overlay: portal the panel to document.body with viewport-clamped\n' +
' geometry, like AccountCombobox\'s dropdown or info-tooltip.tsx.',
)
}
// 2. naive-ore-round: count may not increase.
if (current.naiveOreRound > baseline.naiveOreRound.count) {
failed = true
@@ -1079,11 +1207,18 @@ if (current.naiveOreRound > baseline.naiveOreRound.count) {
}
// Report ratchet-down progress (informational, never fails).
if (fixedAuthFiles.length || fixedLedgerScans.length || current.naiveOreRound < baseline.naiveOreRound.count) {
if (
fixedAuthFiles.length ||
fixedLedgerScans.length ||
fixedDialogOverflow.length ||
current.naiveOreRound < baseline.naiveOreRound.count
) {
console.log('\n✓ Progress since baseline:')
if (fixedAuthFiles.length) console.log(` raw-route-auth: -${fixedAuthFiles.length} file(s)`)
if (fixedLedgerScans.length)
console.log(` ledger-scanning-report: -${fixedLedgerScans.length} file(s)`)
if (fixedDialogOverflow.length)
console.log(` dialog-overflow-risk: -${fixedDialogOverflow.length} file(s)`)
if (current.naiveOreRound < baseline.naiveOreRound.count)
console.log(` naive-ore-round: -${baseline.naiveOreRound.count - current.naiveOreRound} occurrence(s)`)
console.log(' Run with --update to ratchet the baseline down and lock in the gains.')
@@ -1101,5 +1236,5 @@ if (failed) {
process.exit(1)
}
console.log(
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`,
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s)).`,
)