Files
accounted/components/extensions/general/EditKonteringDialog.tsx
T
2e2a64dd0a 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>
2026-08-12 16:13:58 +02:00

137 lines
5.1 KiB
TypeScript

'use client'
/**
* Change everything about a proposed kontering, in one place.
*
* The rail used to offer three overlapping ways to alter a booking, none of
* which said what it covered: an "Ändra" beside the date, an "Ändra kontering"
* at the bottom, and a "Bokför som verifikat" entry in a menu that in practice
* did what the primary button already did. This is the one control, and its
* scope is the whole verifikat: date, series, description, every line.
*
* It is a dialog rather than an inline editor because a 340px rail cannot hold
* an account picker, two money columns and a delete control per row without
* something being clipped, and because the document has to stay readable while
* the numbers are being changed. That is the same shape TransactionBookingDialog
* already uses, for the same reason.
*
* The form is JournalEntryForm, unchanged. It already carries the series
* picker, per-line descriptions, dimensions, currency, the balance check and
* the confirm-before-post step, and it posts through the sanctioned route. The
* alternative was extending BookDirectlyDialog, whose lines are seeded by three
* effects that fight anything injected into them, and whose FormLine has no
* room for line text, dimensions or tax codes.
*/
import { useTranslations } from 'next-intl'
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
export interface ProposedLine {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
export default function EditKonteringDialog({
open,
onOpenChange,
itemId,
documentId,
documentMime,
documentUrl,
fileName,
transactionId,
entryDate,
description,
lines,
onBooked,
}: {
open: boolean
onOpenChange: (open: boolean) => void
itemId: string
documentId: string | null
documentMime: string | null
documentUrl: string | null
fileName: string | null
transactionId: string | null
entryDate: string
description: string
lines: ProposedLine[]
onBooked: (entryId: string) => void
}) {
const t = useTranslations('inbox_workspace')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Ändra kontering</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,480px)]">
<div className="min-w-0">
<JournalEntryForm
// Remount per item so a second underlag never inherits the
// first one's draft: the form seeds its state from the initial
// props once, by design.
key={itemId}
embedded
// 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`}
sourceType={transactionId ? 'bank_transaction' : 'manual'}
sourceId={transactionId ?? undefined}
// source_id is metadata the schema strips. The route needs
// transaction_id to book the underlag against its bank line;
// without it the verifikat posts standalone, the transaction
// stays unbooked and the match is cleared.
extraBody={transactionId ? { transaction_id: transactionId } : undefined}
onEntryCreated={onBooked}
/>
</div>
{/* The document stays readable while the numbers change: checking a
VAT rate against the paper is the reason to open this at all. */}
<aside className="min-w-0">
{documentId ? (
<DocumentViewerPane
documentId={documentId}
mime={documentMime}
downloadUrl={documentUrl}
fileName={fileName}
className="h-[60vh]"
/>
) : (
<div className="h-[60vh] grid place-items-center rounded-lg border text-xs text-muted-foreground">
{t('dialog_no_document')}
</div>
)}
</aside>
</div>
</DialogContent>
</Dialog>
)
}