fix(inbox): say each thing once, and stop explaining what doing it teaches (#1532)

* docs(inbox): the onboarding card described the page as it used to be

Three steps ending at 'matcha mot en transaktion eller bokför', a Beta
badge it had outgrown, and no mention that the page now searches the
mailboxes itself, lists the purchases missing a receipt, or proposes the
kontering.

It now names the three things a person actually does: get an address,
connect a brevlåda so Kvittojakten can look on its own, and approve the
proposed kontering. The pricing line keeps the distinction that matters
(collecting underlag is free; AI-tolkning and the hunt are in the plan)
and drops the Beta badge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(inbox): say each thing once, and stop explaining what doing it teaches

Six things the page said that it did not need to say, or said twice.

The mailbox rows misaligned because the address could not shrink: a long
one pushed the date and Koppla från onto a second line while the provider
mark stayed centred against a now two-line row. min-w-0 lets truncate work.

The settings group was labelled Leta efter underlag directly above a row
labelled Sök igenom brevlådorna. The group is now Kvittojakten, which is
what the rest of the app calls it.

A hunt that found nothing left its line on screen indefinitely. There is
nothing to act on, so it clears after a few seconds. A run that found
something, or failed, still stays: both name a next step.

Ändra kontering opened with no rows at all for an unknown supplier, so
the first move was Lägg till rad before anything could be typed. The form
already defaults to two blank rows when given nothing, but an empty
proposal was passed as [] rather than undefined, which is not the same.
Its description restated the title, and the keyboard tips sat under every
entry form permanently; both are learned by doing.

The matched state was stated twice in one rail, as a bordered box and a
badge. The badge keeps it, and takes over the box's link to the
transaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pending): name the account, not just the line text

A proposal read '5890 Utlägg Norwegian'. 5890 is Övriga resekostnader,
which the preview never said: it printed the account number next to the
line's own description, so the only readable word on the line was one the
proposal wrote about itself. Approving meant trusting a label that never
named what was being debited, and a travel cost looked like an utlägg.

The account's own name now leads, with the line text after it when it
says something the name does not. Same for the VAT lines.

Fetched once and shared across previews; a failed lookup leaves the
number rather than blanking the line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(pending): the account-name map must not outlive the company

Review finding, and the tenancy half is the real one. The lookup was a
module-level promise populated once with ??= and never invalidated, so a
company switch that does not reload the page would keep showing the
previous company's account names against this company's numbers: a wrong
name reads as verified in a way a bare number never does.

It is also permanent on failure. A single transient error resolved the
cached promise to {} for the rest of the session, with no retry short of
a reload.

The page owns it now and passes it down by context: one fetch per mount,
gone when the page is, and a failure leaves the bare number and retries
next time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-12 16:13:58 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent 845add4573
commit 2e2a64dd0a
6 changed files with 124 additions and 41 deletions
+72 -3
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, useMemo, Fragment } from 'react'
import { useState, useEffect, useCallback, useMemo, Fragment, createContext, useContext } from 'react'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -304,7 +304,52 @@ function formatRelativeTime(dateStr: string): string {
return `${diffDays} dagar sedan`
}
/**
* Account number -> account name, for the proposal previews.
*
* A preview line showed the account number next to the line's own description,
* so "5890 Utlägg Norwegian" hid the fact that 5890 is Övriga resekostnader.
* The number alone is not readable and the description is not the account, so
* approving meant trusting a label that never named what was being debited.
*
* Owned by the page rather than a module-level cache: the map is per company,
* and a cache that outlives the page would keep serving one company's account
* names after a switch. A failed fetch leaves the map empty, which shows the
* bare number rather than a wrong name, and retries on the next mount.
*/
const AccountNamesContext = createContext<Record<string, string>>({})
function useAccountNamesSource(): Record<string, string> {
const [names, setNames] = useState<Record<string, string>>({})
useEffect(() => {
let alive = true
void fetch('/api/bookkeeping/accounts')
.then((r) => r.json())
.then(({ data }) => {
if (!alive) return
setNames(
Object.fromEntries(
((data ?? []) as Array<{ account_number: string; account_name: string }>).map((a) => [
a.account_number,
a.account_name,
]),
),
)
})
.catch(() => {
// Display-only: the number still shows, so a failure is not worth
// surfacing as an error the user cannot act on.
})
return () => {
alive = false
}
}, [])
return names
}
function CategorizePreview({ data }: { data: Record<string, unknown> }) {
const accountNames = useContext(AccountNamesContext)
// The exact journal lines the approval will post (net cost line, VAT line,
// gross bank line, SEK) — staged by the server since the preview-lines fix.
const lines = (data.lines as Array<{ account_number?: string; debit_amount?: number; credit_amount?: number; description?: string }>) || []
@@ -319,7 +364,21 @@ function CategorizePreview({ data }: { data: Record<string, unknown> }) {
const creditAmt = typeof line.credit_amount === 'number' ? line.credit_amount : 0
return (
<div key={i} className="flex justify-between gap-4 font-mono text-xs">
<span className="truncate">{line.account_number ?? '?'}{line.description ? ` ${line.description}` : ''}</span>
<span className="truncate">
{line.account_number ?? '?'}{' '}
{/* The account's own name first: it is what the posting means.
The line text follows only when it adds something the name
does not already say. */}
<span className="text-foreground">
{(line.account_number && accountNames[line.account_number]) || line.description || ''}
</span>
{line.description &&
line.account_number &&
accountNames[line.account_number] &&
line.description !== accountNames[line.account_number] ? (
<span className="text-muted-foreground"> · {line.description}</span>
) : null}
</span>
<span className="tabular-nums shrink-0">
{debitAmt > 0 ? `D ${formatCurrency(debitAmt)}` : `K ${formatCurrency(creditAmt)}`}
</span>
@@ -369,7 +428,14 @@ function CategorizePreview({ data }: { data: Record<string, unknown> }) {
<p className="text-xs text-muted-foreground mb-1">Momsrader</p>
{vatLines.map((line, i) => (
<div key={i} className="flex justify-between font-mono text-xs">
<span>{line.account_number} {line.description}</span>
<span>
{line.account_number}{' '}
{accountNames[line.account_number] || line.description}
{accountNames[line.account_number] &&
line.description !== accountNames[line.account_number] ? (
<span className="text-muted-foreground"> · {line.description}</span>
) : null}
</span>
<span className="tabular-nums">
{line.debit_amount > 0 ? `D ${formatCurrency(line.debit_amount)}` : `K ${formatCurrency(line.credit_amount)}`}
</span>
@@ -753,6 +819,7 @@ type ViewTab = 'pending' | 'history'
export default function PendingOperationsPage() {
const t = useTranslations('pending')
const accountNames = useAccountNamesSource()
const [operations, setOperations] = useState<PendingOperation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<ViewTab>('pending')
@@ -1124,6 +1191,7 @@ export default function PendingOperationsPage() {
]
return (
<AccountNamesContext.Provider value={accountNames}>
<div className="space-y-8">
{/* Page header (concept scene 11): title + Godkänn alla */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -1704,5 +1772,6 @@ export default function PendingOperationsPage() {
</DialogContent>
</Dialog>
</div>
</AccountNamesContext.Provider>
)
}
@@ -1855,9 +1855,6 @@ export default function JournalEntryForm({
{t('save_as_template')}
</Button>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
{t('fill_balance_hint')} {t('keyboard_hint')}
</p>
</div>
{/* Document attachments: hidden when editing a draft; underlag is
@@ -30,7 +30,6 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
export interface ProposedLine {
@@ -74,9 +73,6 @@ export default function EditKonteringDialog({
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Ändra kontering</DialogTitle>
<DialogDescription>
Förslaget är en utgångspunkt. Ändra konto, belopp, datum eller serie innan du bokför.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,480px)]">
@@ -87,12 +83,21 @@ export default function EditKonteringDialog({
// props once, by design.
key={itemId}
embedded
initialLines={lines.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount ? String(l.debit_amount) : '',
credit_amount: l.credit_amount ? String(l.credit_amount) : '',
line_description: l.description,
}))}
// An unknown supplier has no proposal, and passing [] here is
// not the same as passing nothing: the form seeds two blank rows
// only when this is undefined, so an empty array opened the
// dialog with no rows at all and a "lägg till rad" between the
// user and typing anything.
initialLines={
lines.length > 0
? lines.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount ? String(l.debit_amount) : '',
credit_amount: l.credit_amount ? String(l.credit_amount) : '',
line_description: l.description,
}))
: undefined
}
initialDate={entryDate}
initialDescription={description}
submitUrl={`/api/extensions/ext/invoice-inbox/items/${itemId}/book-direct`}
@@ -568,11 +568,24 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
hunting,
progress: huntProgress,
result: huntResult,
setResult: setHuntResult,
} = useReceiptHunt(() => {
void fetchItems()
void fetchPurchases()
})
// A run that found nothing leaves nothing to act on, so the line has no
// reason to outlive the glance that reads it. A run that found something,
// or failed, stays: both name a next step (press again, or a mailbox to
// check) and both are worth still being on screen a minute later.
useEffect(() => {
if (hunting || !huntResult) return
if (huntResult.failed || huntResult.fetched > 0) return
const timer = setTimeout(() => setHuntResult(null), 6000)
return () => clearTimeout(timer)
}, [hunting, huntResult, setHuntResult])
const selectedPurchase = useMemo(
() => purchases.find((p) => p.id === selectedPurchaseId) ?? null,
[purchases, selectedPurchaseId],
@@ -2506,8 +2519,7 @@ const SUGGESTION_SOURCE_LABEL: Record<string, string> = {
/** Why there is no proposal, said plainly rather than shown as an empty table. */
const SUGGESTION_EMPTY_REASON: Record<string, string> = {
no_mapping:
'Vi har inget förslag: leverantören är obekant och ingen regel matchar. Bokför manuellt en gång, så känns den igen nästa gång.',
no_mapping: 'Okänd leverantör. Bokför en gång, så känns den igen.',
currency_unsupported:
'Köpet är i utländsk valuta och matchades av en konteringsregel. Momsen skulle bli fel, så vi visar inget förslag.',
}
@@ -2907,9 +2919,8 @@ function FieldsRail({
{/* Hint only: creation happens on the leverantörsfaktura form via "Skapa & välj" */}
{showNoMatchHint && (
<div className="border-b bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
Ingen leverantör matchade{' '}
<span className="text-foreground font-medium">{extractedSupplierName}</span>
{': leverantören skapas när du klickar Skapa leverantörsfaktura.'}
{' finns inte upplagd än. Den skapas när du gör leverantörsfakturan.'}
</div>
)}
@@ -3039,19 +3050,6 @@ function FieldsRail({
{/* Matched-to-tx state: show the bridge to booking. The user
picks one of two actions: book themselves with the
deterministic dialog, or hand off to the assistant. */}
<div className="rounded-md border border-success/30 bg-success/5 px-3 py-2 text-xs">
<div className="flex items-center gap-1.5 text-success font-medium mb-1">
<Link2 className="h-3 w-3" />
Matchad mot transaktion
</div>
<Link
href={`/transactions?highlight=${item.matched_transaction_id}`}
className="text-muted-foreground hover:text-foreground hover:underline"
>
Öppna transaktionen →
</Link>
</div>
{onAskAssistant && (
<Button
variant="default"
@@ -3176,12 +3174,26 @@ function FieldsRail({
Bokförd
</Badge>
)}
{isLinkedToTransaction && (
<Badge variant="secondary" className="w-full justify-center text-[10px]">
<Link2 className="h-2.5 w-2.5 mr-1" />
Kopplad till transaktion
</Badge>
)}
{isLinkedToTransaction &&
(item.matched_transaction_id ? (
<Link
href={`/transactions?highlight=${item.matched_transaction_id}`}
className="w-full"
>
<Badge
variant="secondary"
className="w-full justify-center text-[10px] hover:bg-secondary/80"
>
<Link2 className="h-2.5 w-2.5 mr-1" />
Kopplad till transaktion
</Badge>
</Link>
) : (
<Badge variant="secondary" className="w-full justify-center text-[10px]">
<Link2 className="h-2.5 w-2.5 mr-1" />
Kopplad till transaktion
</Badge>
))}
</div>
)}
@@ -129,7 +129,7 @@ export function MailConnectionsPanel() {
</span>
}
>
<span className="truncate">{connection.emailAddress}</span>
<span className="min-w-0 flex-1 truncate">{connection.emailAddress}</span>
{connection.status === 'needs_reconsent' ? (
<Badge variant="warning">{t('needs_reconsent')}</Badge>
) : null}
+1 -1
View File
@@ -326,7 +326,7 @@
"mail": {
"hunt_stop": "Stoppa",
"hunt_progress": "{fetched} hämtade…",
"hunt_title": "Leta efter underlag",
"hunt_title": "Kvittojakten",
"hunt_help": "Vi söker i de kopplade brevlådorna efter kvitton och fakturor till köp som saknar underlag, läser beloppet ur filen och lägger fram förslagen i Granskning. Inget bokförs.",
"hunt_row": "Sök igenom brevlådorna",
"hunt_action": "Leta nu",