From abff8d90f6bd84972ea0fabf0bfc72d1ed6c97b6 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 13 Aug 2026 11:20:00 +0200 Subject: [PATCH] refactor(ui): validation as errors on attempt, help text behind the ? (#1561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/(dashboard)/bookkeeping/[id]/page.tsx | 1 - .../bookkeeping/InboxDocumentPicker.tsx | 1 - components/bookkeeping/JournalEntryForm.tsx | 54 +++++++++++++------ components/bookkeeping/JournalEntryList.tsx | 1 - components/invoices/InvoiceEditor.tsx | 4 -- .../MatchVerifikationPicker.tsx | 40 +++++++++----- .../NewSupplierInvoiceForm.tsx | 2 - .../transactions/MatchVoucherDialog.tsx | 51 +++++++++++------- messages/en.json | 9 ---- messages/sv.json | 9 ---- 10 files changed, 95 insertions(+), 77 deletions(-) diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 5197e28b..388945da 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -936,7 +936,6 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i

{t('references_title')}

-

{t('references_subtitle')}

    {references.map((ref) => ( diff --git a/components/bookkeeping/InboxDocumentPicker.tsx b/components/bookkeeping/InboxDocumentPicker.tsx index 78621d64..e014d693 100644 --- a/components/bookkeeping/InboxDocumentPicker.tsx +++ b/components/bookkeeping/InboxDocumentPicker.tsx @@ -172,7 +172,6 @@ export default function InboxDocumentPicker({ open, onClose, journalEntryId, onL {t('picker_title')} - {t('picker_description')}
    diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 44690543..1b1203d5 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -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([]) const [accounts, setAccounts] = useState([]) // 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" /> -

    {t('dimensions_apply_all_hint')}

    )} @@ -1880,7 +1902,7 @@ export default function JournalEntryForm({ {editEntryId ? (
- {(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && ( -
+ {showValidationHints && (!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && ( +
{!description &&

{t('validation_description')}

} {!selectedPeriod &&

{t('validation_period')}

} {periodMismatch === 'no_period' &&

{t('validation_no_matching_period')}

} diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 75190922..e235c360 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -1066,7 +1066,6 @@ export default function JournalEntryList() { {t('sort_description_desc')} -

{t('sort_stack_hint')}

{/* Verifikationsserie */} diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 83c5f1ed..56ad31c8 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -1565,7 +1565,6 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat {t('items_card_title')} - {t('items_card_description')}
@@ -2386,9 +2385,6 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat onChange={setDefaultDimension} inputClassName="h-9" /> -

- {t('dimensions_default_hint')} -

)} diff --git a/components/reconciliation/MatchVerifikationPicker.tsx b/components/reconciliation/MatchVerifikationPicker.tsx index ee6d8d9d..7a416f7d 100644 --- a/components/reconciliation/MatchVerifikationPicker.tsx +++ b/components/reconciliation/MatchVerifikationPicker.tsx @@ -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 (
{formatVoucher(selected)} @@ -140,9 +144,13 @@ export function MatchVerifikationPicker({ {formatCurrency(amount)} {selected.entry_description} {strength && ( - - {strength.label} - + strength.variant ? ( + + {strength.label} + + ) : ( + {strength.label} + ) )} {(selected.linked_transaction_count ?? 0) > 0 && ( @@ -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 (
-

{t('dimensions_default_hint')}

)} diff --git a/components/transactions/MatchVoucherDialog.tsx b/components/transactions/MatchVoucherDialog.tsx index 9f966a4b..518b4c5b 100644 --- a/components/transactions/MatchVoucherDialog.tsx +++ b/components/transactions/MatchVoucherDialog.tsx @@ -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({ - Matcha mot befintlig verifikation - - 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. - + {/* Convention 7: the how-it-works copy lives behind the "?", not in + the dialog flow. */} +
+ Matcha mot befintlig verifikation + +

+ 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. +

+

+ Med "Visa även matchade" kan flera bankhändelser kopplas + till samma verifikation, t.ex. en lön utbetald i flera + överföringar. +

+
+
{/* Transaction summary */} @@ -240,26 +251,26 @@ export function MatchVoucherDialog({ {(selectedLine?.linked_transaction_count ?? 0) > 0 && (

- 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.

)} )} {/* 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). */}
- + {!wideRange && (