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:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
845add4573
commit
2e2a64dd0a
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user