refactor(ui): validation as errors on attempt, help text behind the ? (#1561)

The founder flagged the app as bloated with standing instructional text.

Ny verifikation: the what's-missing lines (Ange en beskrivning, Minst
två rader...) rendered from the first frame because their only gate was
form validity, which an empty form fails: instructions dressed as
validation. The submit buttons now stay enabled and an attempt on an
incomplete form is what surfaces the lines, in destructive red, per the
error-on-submit idiom. The Enter-to-advance flow keeps the old
completeness predicate so navigation is untouched.

Matcha mot befintlig verifikation: the two-sentence explainer moved
behind a ? (HelpPopover, convention 7), the N:1 note tightened, the
Visa även matchade switch became a quiet link (switches are settings
idiom), and Stark träff, the normal auto-selected case, renders as
muted text instead of a chip (chips mark exceptions, convention 5).

Deleted always-visible paraphrase lines and their orphaned keys:
items_card_description, picker_description, references_subtitle,
sort_stack_hint, dimensions hints, document_help, and the dead
fill_balance_hint key that had no render site at all.

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-13 11:20:00 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 1e9f245f7c
commit abff8d90f6
10 changed files with 95 additions and 77 deletions
@@ -172,7 +172,6 @@ export default function InboxDocumentPicker({ open, onClose, journalEntryId, onL
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('picker_title')}</DialogTitle>
<DialogDescription>{t('picker_description')}</DialogDescription>
</DialogHeader>
<div className="relative">
+38 -16
View File
@@ -204,6 +204,10 @@ export default function JournalEntryForm({
const [isSavingDraft, setIsSavingDraft] = useState(false)
const saveAsDraftRef = useRef(false)
const [showNoDocWarning, setShowNoDocWarning] = useState(false)
// The what's-missing lines render only after a submit attempt (error-on-
// submit), never as standing chrome on an empty form. The buttons stay
// enabled so the attempt can happen; the handlers gate on validity.
const [showValidationHints, setShowValidationHints] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
// Full BAS catalogue (static reference data, fetched once per session). Lets
@@ -809,7 +813,11 @@ export default function JournalEntryForm({
}
const handleReview = () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
if (!selectedPeriod || !description || !isBalanced || periodMismatch) {
setShowValidationHints(true)
return
}
setShowValidationHints(false)
const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded')
if (!embedded && !bare && !hasDocuments) {
setShowNoDocWarning(true)
@@ -818,17 +826,24 @@ export default function JournalEntryForm({
setShowReview(true)
}
// Whether an Enter should open the review: mirrors the review button's
// enable gate exactly, so Enter never submits something the button wouldn't.
// Nothing in flight and the user may write: the buttons' enable gate.
// Validity is deliberately NOT part of it; an attempt on an incomplete
// form is what surfaces the validation hints.
const processReady = () =>
!isUploading &&
canWrite &&
!isSubmitting &&
!isSavingDraft
// Whether the entry is actually submittable. The Enter-to-advance handlers
// below key off this: navigation fires while the entry is incomplete, and
// once it balances Enter falls through to the review instead.
const canSubmitReview = () =>
isBalanced &&
!!description &&
!!selectedPeriod &&
!periodMismatch &&
!isUploading &&
canWrite &&
!isSubmitting &&
!isSavingDraft
processReady()
// Enter anywhere in the form = "Granska & skapa": opens the review exactly as
// the button does, from any field. Navigation is Tab's job. Two Enter
@@ -840,7 +855,7 @@ export default function JournalEntryForm({
if (e.defaultPrevented || showReview) return
if ((e.target as HTMLElement).tagName === 'TEXTAREA') return
e.preventDefault()
if (canSubmitReview()) handleReview()
if (processReady()) handleReview()
}
// Enter-to-advance inside the konteringsrader: konto → debet → kredit →
@@ -1132,7 +1147,11 @@ export default function JournalEntryForm({
}
const handleSaveDraft = async () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
if (!selectedPeriod || !description || !isBalanced || periodMismatch) {
setShowValidationHints(true)
return
}
setShowValidationHints(false)
setIsSavingDraft(true)
saveAsDraftRef.current = true
try {
@@ -1198,7 +1217,11 @@ export default function JournalEntryForm({
// editEntryId URL) and keep it a draft. No field reset: the host dialog
// closes on success via onUpdated.
const handleSaveEdit = async () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
if (!selectedPeriod || !description || !isBalanced || periodMismatch) {
setShowValidationHints(true)
return
}
setShowValidationHints(false)
setIsSavingDraft(true)
try {
await runSubmit()
@@ -1483,7 +1506,6 @@ export default function JournalEntryForm({
onChange={setHeaderDimension}
inputClassName="h-8"
/>
<p className="text-xs text-muted-foreground">{t('dimensions_apply_all_hint')}</p>
</div>
)}
@@ -1880,7 +1902,7 @@ export default function JournalEntryForm({
{editEntryId ? (
<Button
onClick={handleSaveEdit}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
disabled={isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
@@ -1907,7 +1929,7 @@ export default function JournalEntryForm({
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
disabled={isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? t('read_only_tooltip') : t('save_draft_tooltip')}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
@@ -1916,7 +1938,7 @@ export default function JournalEntryForm({
)}
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
disabled={isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
@@ -1925,8 +1947,8 @@ export default function JournalEntryForm({
</>
)}
</div>
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
{showValidationHints && (!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
<div className="text-xs text-destructive space-y-0.5 text-right">
{!description && <p>{t('validation_description')}</p>}
{!selectedPeriod && <p>{t('validation_period')}</p>}
{periodMismatch === 'no_period' && <p>{t('validation_no_matching_period')}</p>}
@@ -1066,7 +1066,6 @@ export default function JournalEntryList() {
<SelectItem value="description_desc">{t('sort_description_desc')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('sort_stack_hint')}</p>
</div>
{/* Verifikationsserie */}
-4
View File
@@ -1565,7 +1565,6 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
<Card>
<CardHeader>
<CardTitle>{t('items_card_title')}</CardTitle>
<CardDescription>{t('items_card_description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
@@ -2386,9 +2385,6 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
onChange={setDefaultDimension}
inputClassName="h-9"
/>
<p className="text-xs text-muted-foreground">
{t('dimensions_default_hint')}
</p>
</div>
</>
)}
@@ -10,15 +10,19 @@ import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
/**
* Map the endpoint's 0-1 match confidence (attached only when candidates are
* ranked for a specific transaction) to a labelled strength badge, so the user
* can tell an exact-amount hit from a fuzzy guess before vouching for an
* immutable verifikat. Returns null when no confidence was attached.
* ranked for a specific transaction) to a strength label, so the user can tell
* an exact-amount hit from a fuzzy guess before vouching for an immutable
* verifikat. Returns null when no confidence was attached.
*
* Chips mark exceptions (convention 5): a strong hit is the normal,
* auto-selected case and renders as muted text; only the fuzzy guesses that
* deserve a second look get a chip.
*/
function confidenceBadge(
function confidenceMark(
confidence: number | undefined,
): { label: string; variant: 'success' | 'secondary' | 'outline' } | null {
): { label: string; variant: 'secondary' | 'outline' | null } | null {
if (confidence == null) return null
if (confidence >= 0.85) return { label: 'Stark träff', variant: 'success' }
if (confidence >= 0.85) return { label: 'Stark träff', variant: null }
if (confidence >= 0.6) return { label: 'Trolig träff', variant: 'secondary' }
return { label: 'Svag träff', variant: 'outline' }
}
@@ -132,7 +136,7 @@ export function MatchVerifikationPicker({
// green "Stark träff" can't visually encourage an accidental double-match:
// "Redan matchad" is the signal that matters there (N:1 stays opt-in).
const strength =
(selected.linked_transaction_count ?? 0) > 0 ? null : confidenceBadge(selected.confidence)
(selected.linked_transaction_count ?? 0) > 0 ? null : confidenceMark(selected.confidence)
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/40 px-3 py-2 text-sm">
<span className="font-mono text-xs shrink-0">{formatVoucher(selected)}</span>
@@ -140,9 +144,13 @@ export function MatchVerifikationPicker({
<span className="tabular-nums shrink-0">{formatCurrency(amount)}</span>
<span className="truncate text-muted-foreground flex-1 min-w-0">{selected.entry_description}</span>
{strength && (
<Badge variant={strength.variant} className="shrink-0 text-[10px]">
{strength.label}
</Badge>
strength.variant ? (
<Badge variant={strength.variant} className="shrink-0 text-[10px]">
{strength.label}
</Badge>
) : (
<span className="shrink-0 text-[11px] text-muted-foreground">{strength.label}</span>
)
)}
{(selected.linked_transaction_count ?? 0) > 0 && (
<Badge variant="secondary" className="shrink-0 text-[10px]">
@@ -175,7 +183,7 @@ export function MatchVerifikationPicker({
{filtered.map((line) => {
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
const strength =
(line.linked_transaction_count ?? 0) > 0 ? null : confidenceBadge(line.confidence)
(line.linked_transaction_count ?? 0) > 0 ? null : confidenceMark(line.confidence)
return (
<button
key={line.line_id}
@@ -200,9 +208,13 @@ export function MatchVerifikationPicker({
{line.line_description || line.entry_description}
</span>
{strength && (
<Badge variant={strength.variant} className="shrink-0 text-[10px]">
{strength.label}
</Badge>
strength.variant ? (
<Badge variant={strength.variant} className="shrink-0 text-[10px]">
{strength.label}
</Badge>
) : (
<span className="shrink-0 text-[11px] text-muted-foreground">{strength.label}</span>
)
)}
{(line.linked_transaction_count ?? 0) > 0 && (
<Badge variant="secondary" className="shrink-0 text-[10px]">
@@ -1837,7 +1837,6 @@ export default function NewSupplierInvoiceForm({
<Paperclip className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<Label>{t('document_label')}</Label>
<p className="text-xs text-muted-foreground">{t('document_help')}</p>
</div>
</div>
<DocumentUploadZone
@@ -1862,7 +1861,6 @@ export default function NewSupplierInvoiceForm({
inputClassName="h-9"
/>
</div>
<p className="text-xs text-muted-foreground">{t('dimensions_default_hint')}</p>
</div>
)}
+31 -20
View File
@@ -6,11 +6,10 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { HelpPopover } from '@/components/ui/help-popover'
import {
MatchVerifikationPicker,
type UnlinkedGLLine,
@@ -188,11 +187,23 @@ export function MatchVoucherDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Matcha mot befintlig verifikation</DialogTitle>
<DialogDescription>
Koppla bankhändelsen till en verifikation som redan är bokförd (t.ex. en
lön eller en post importerad från Fortnox). Ingen ny bokföring skapas.
</DialogDescription>
{/* Convention 7: the how-it-works copy lives behind the "?", not in
the dialog flow. */}
<div className="flex items-center gap-2">
<DialogTitle>Matcha mot befintlig verifikation</DialogTitle>
<HelpPopover>
<p>
Kopplar bankhändelsen till en verifikation som redan är bokförd,
t.ex. en lön eller en post importerad från Fortnox. Ingen ny
bokföring skapas.
</p>
<p className="mt-2">
Med &quot;Visa även matchade&quot; kan flera bankhändelser kopplas
till samma verifikation, t.ex. en lön utbetald i flera
överföringar.
</p>
</HelpPopover>
</div>
</DialogHeader>
{/* Transaction summary */}
@@ -240,26 +251,26 @@ export function MatchVoucherDialog({
<MatchVerifikationPicker glLines={glLines} value={selected} onChange={setSelected} inline />
{(selectedLine?.linked_transaction_count ?? 0) > 0 && (
<p className="text-xs text-muted-foreground">
Verifikationen är redan matchad mot {selectedLine?.linked_transaction_count}{' '}
transaktion{(selectedLine?.linked_transaction_count ?? 0) === 1 ? '' : 'er'}.
Kopplingen lägger till den här transaktionen också: t.ex. en lön utbetald i
flera överföringar.
Redan matchad mot {selectedLine?.linked_transaction_count}{' '}
transaktion{(selectedLine?.linked_transaction_count ?? 0) === 1 ? '' : 'er'};
den här läggs till.
</p>
)}
</>
)}
{/* Discovery affordances: widen the date window, and surface vouchers
already matched so another transaction can be attached (N:1). */}
already matched so another transaction can be attached (N:1).
Quiet links, not switches: these are list filters, and the switch
idiom belongs to settings (convention 15). */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 pt-1">
<label className="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={includeMatched}
onCheckedChange={setIncludeMatched}
aria-label="Visa även matchade verifikationer"
/>
Visa även matchade verifikationer
</label>
<button
type="button"
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={() => setIncludeMatched((v) => !v)}
>
{includeMatched ? 'Dölj matchade' : 'Visa även matchade'}
</button>
{!wideRange && (
<button
type="button"