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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
68ca152127
commit
834cc4d0e8
@@ -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.
|
||||
|
||||
@@ -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 "{search.trim()}"</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 "{search.trim()}"</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
|
||||
"Flytta till annat datum" 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 "Flytta till annat datum" 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)).`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user