402bf02b1b
PR #1524 swapped the matched-state "Bokfor manuellt" path from BookDirectlyDialog to EditKonteringDialog, which is seeded only from the booking proposal. An unknown supplier has no proposal, so the dialog opened with two blank rows and no amount at all: on a foreign-currency invoice the SEK figure then existed nowhere on screen (user-reported regression, 2026-08-12). suggest-booking now returns, on every empty-proposal branch (no_mapping, currency_unsupported, engine failure), the matched bank row's SEK amount and date plus a balanced two-row skeleton: the settlement account on one side, a blank cost row on the other, mirroring what buildPrefillLines seeded before the swap. The SEK amount goes through resolveSekAmountOrNull, so a foreign row with no honest kronor figure still opens blank rather than relabeling EUR as SEK. The dialog also shows the matched transaction's amount and date beside the title, and empty proposals now carry the bank date so the entry no longer falls back to the document date. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
150 lines
5.8 KiB
TypeScript
150 lines
5.8 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 { formatCurrency, formatDate } from '@/lib/utils'
|
|
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,
|
|
matchedTransaction = null,
|
|
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[]
|
|
/** SEK amount and date of the matched bank row, when there is one. Shown
|
|
beside the title so the kronor figure stays visible even if the user
|
|
clears the rows: on a foreign-currency invoice this is the only place
|
|
the SEK amount exists at all. */
|
|
matchedTransaction?: { amount_sek: number; date: string } | null
|
|
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>
|
|
{matchedTransaction && (
|
|
<p className="text-xs text-muted-foreground tabular-nums">
|
|
{t('dialog_matched_transaction')}: {formatCurrency(matchedTransaction.amount_sek)} ·{' '}
|
|
{formatDate(matchedTransaction.date)}
|
|
</p>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|