Files
accounted/components/transactions/TransactionBookingDialog.tsx
T
Jakob Wennberg 567fae654c perf(bookkeeping): booking dialogs render populated on open from the session cache (#1935)
The bookkeeping dialogs were the customer's "fields load late" in its
purest form: Bokför (TransactionBookingDialog + the embedded
JournalEntryForm) issued five requests on every open (fiscal periods,
accounts, settings, cash accounts, then the voucher preview once the first
two had landed), Nytt verifikat the same minus one, BookDirectlyDialog
four, and the template dialogs two. Each Radix dialog unmounts on close, so
every reopen paid the full price again, and several fields visibly
flipped: the bank line seeded '1930' then rewrote itself, the series
defaulted to 'A' until settings arrived, the period select was empty.

All of them now read lib/reference-data (seeded by the dashboard layout):

- JournalEntryForm: periods, accounts and settings from the hooks;
  dimensionsEnabled derived, not fetched; the voucher-number preview is
  keyed on the entry date (the route resolves the period from it) so it
  fires as soon as the series is known instead of after the period fetch;
  after activating accounts it invalidates the shared accounts cache; the
  create-period dialog callback invalidates the periods cache.
- TransactionBookingDialog: settlement account and its name derived with
  useMemo from the cached cash accounts; the form mounts on the first paint.
- BookDirectlyDialog: cash accounts, periods and accounts from the hooks;
  the '1930'-then-rewrite disappears because the resolved account is known
  on the first render.
- TemplateBookDialog, BookingTemplatePicker, TemplatePicker: templates
  (and periods) from the hooks.
- BookingTemplatesPanel (delete, import) and CreatePeriodDialog (create)
  invalidate the corresponding cache entries so every picker sees the
  change at once.
- fetchers.ts: booking templates are booking_templates rows
  (BookingTemplateLibrary), not the static BookingTemplate shape.

Per open: Bokför 5 requests -> 0 blocking (voucher preview is a
non-blocking hint), Nytt verifikat 5 -> 1 non-blocking, BookDirectly
4 -> 0, Mall 2 -> 0, template pickers 1 -> 0.
raw-reference-fetch ratchet: 51 -> 46 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:37:28 +02:00

440 lines
18 KiB
TypeScript

'use client'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, Inbox, X } from 'lucide-react'
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPicker'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils'
import { applyTemplate } from '@/lib/bookkeeping/template-library'
import { proposalLinesToFormLines } from '@/lib/bookkeeping/proposal-lines'
import type { ProposalLine } from '@/lib/bookkeeping/proposal-lines'
import type { BookingTemplateLibrary } from '@/types'
import type { TransactionWithInvoice } from './transaction-types'
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
import { useCashAccounts } from '@/lib/reference-data/hooks'
interface TransactionBookingDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onBooked: (
transactionId: string,
journalEntryId: string,
attachedDocumentId?: string | null,
/** True when the transaction was LINKED to an existing voucher via the
* duplicate guard's match action: no new verifikat was created. */
matched?: boolean,
) => void
preselectedTemplate?: BookingTemplateLibrary | null
/**
* "Andra rader" hand-off from a proposal view (QuickReviewDialog): the
* COMPUTED lines the user was shown, prefilled for per-line editing. Takes
* precedence over preselectedTemplate. The settlement leg's account is
* swapped for the transaction's resolved cash account, same as the
* library-template path.
*/
proposalLines?: ProposalLine[] | null
/** Account number (string, e.g. '5460') to prefill on the counter line:
* set when the user picked an account from the template picker's "Konton"
* search results. Ignored when a preselectedTemplate is present. */
preselectedAccount?: string | null
}
function buildInitialLines(
transaction: TransactionWithInvoice,
bankLineDescription: string,
bankAccount: string = '1930',
counterAccount?: string | null,
): FormLine[] {
const sekAmount = Math.round(Math.abs(resolveSekAmount(
transaction.amount,
transaction.amount_sek,
transaction.currency,
transaction.exchange_rate
)) * 100) / 100
const amountStr = sekAmount.toFixed(2)
const isExpense = transaction.amount < 0
const isForeign = !!transaction.currency && transaction.currency !== 'SEK'
const currencyMeta = isForeign
? buildCurrencyMetadata(
transaction.currency,
Math.abs(transaction.amount),
transaction.exchange_rate
)
: {}
const bankLine: FormLine = {
account_number: bankAccount,
debit_amount: isExpense ? '' : amountStr,
credit_amount: isExpense ? amountStr : '',
line_description: bankLineDescription,
...currencyMeta,
}
const counterLine: FormLine = {
account_number: counterAccount ?? '',
debit_amount: isExpense ? amountStr : '',
credit_amount: isExpense ? '' : amountStr,
line_description: '',
}
return isExpense ? [bankLine, counterLine] : [bankLine, counterLine]
}
function buildInitialLinesFromTemplate(
transaction: TransactionWithInvoice,
template: BookingTemplateLibrary,
bankAccount: string = '1930',
): FormLine[] {
const sekAmount = Math.round(Math.abs(resolveSekAmount(
transaction.amount,
transaction.amount_sek,
transaction.currency,
transaction.exchange_rate
)) * 100) / 100
const lines = applyTemplate(template.lines, sekAmount)
const isForeign = !!transaction.currency && transaction.currency !== 'SEK'
const currencyMeta = isForeign
? buildCurrencyMetadata(
transaction.currency,
Math.abs(transaction.amount),
transaction.exchange_rate
)
: {}
return lines.map((line, i) => {
const raw = template.lines[i]
if (raw?.type === 'settlement') {
return { ...line, ...(isForeign ? currencyMeta : {}), account_number: bankAccount }
}
return line
})
}
export default function TransactionBookingDialog({
open,
onOpenChange,
transaction,
onBooked,
preselectedTemplate,
proposalLines,
preselectedAccount,
}: TransactionBookingDialogProps) {
const t = useTranslations('tx_booking_dialog')
const { toast } = useToast()
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [pickedInboxDocs, setPickedInboxDocs] = useState<AvailableInboxDoc[]>([])
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
// Settlement account for the bank line: resolved from the session-cached
// cash accounts (seeded by the dashboard layout), so the form below mounts
// on the first paint instead of after a /api/cash-accounts round trip on
// every open. null only while the list is genuinely still loading.
const { cashAccounts, isLoading: cashAccountsLoading } = useCashAccounts()
const { bankAccount, bankAccountName } = useMemo(() => {
if (!transaction || cashAccountsLoading) {
return { bankAccount: null as string | null, bankAccountName: null as string | null }
}
const { account } = resolveAccount(
cashAccounts,
transaction.cash_account_id ?? null,
transaction.currency ?? 'SEK',
)
// Use the matched account's own name instead of a generic label.
const matched =
cashAccounts.find((a) => a.id === transaction.cash_account_id) ??
cashAccounts.find((a) => a.ledger_account === account)
return { bankAccount: account, bankAccountName: matched?.name ?? null }
}, [transaction, cashAccounts, cashAccountsLoading])
if (!transaction) return null
const isIncome = transaction.amount > 0
const handleBooked = async (transactionId: string, journalEntryId: string, matched = false) => {
// Link any attached documents to the new journal entry: freshly uploaded
// files, and existing inbox documents picked via InboxDocumentPicker. For
// picked docs, inbox_item_id stamps the inbox item as consumed so it drops
// out of the active inbox: see app/api/documents/[id]/link/route.ts.
// transaction_id additionally pins the doc to the transaction row so the
// /transactions list shows the underlag indicator (first linked doc wins).
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
let linkFailCount = 0
let firstLinkedDocId: string | null = null
for (const file of filesToLink) {
try {
const res = await fetch(`/api/documents/${file.id}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
journal_entry_id: journalEntryId,
transaction_id: transactionId,
}),
})
if (!res.ok) linkFailCount++
else firstLinkedDocId ??= file.id ?? null
} catch {
linkFailCount++
}
}
for (const doc of pickedInboxDocs) {
try {
const res = await fetch(`/api/documents/${doc.document_id}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
journal_entry_id: journalEntryId,
inbox_item_id: doc.inbox_item_id,
transaction_id: transactionId,
}),
})
if (!res.ok) linkFailCount++
else firstLinkedDocId ??= doc.document_id
} catch {
linkFailCount++
}
}
if (linkFailCount > 0) {
toast({
title: t('doc_link_failed_title'),
description: t('doc_link_failed_description', { count: linkFailCount }),
variant: 'destructive',
})
}
// The server pins only when the tx has no document_id yet (first linked
// doc wins): mirror that here so the optimistic state never claims a
// pin the server refused to swap.
const pinnedDocId = transaction.document_id ? null : firstLinkedDocId
if (pinnedDocId) {
// Same event AgentChat dispatches after uploads: flips the inbox card's
// paperclip optimistically without a refetch.
window.dispatchEvent(
new CustomEvent('Accounted:transaction-document-linked', {
detail: { transaction_id: transactionId, document_id: pinnedDocId },
}),
)
}
setUploadedFiles([])
setPickedInboxDocs([])
onBooked(transactionId, journalEntryId, pinnedDocId, matched)
}
// The receipt to show beside the form. A transaction may arrive with a
// pre-linked document; otherwise the user attaches one in-dialog (upload or
// inbox pick) and it appears here as soon as it's available.
const uploadedDoc = uploadedFiles.find((f) => f.status === 'uploaded' && f.id)
const pickedDoc = pickedInboxDocs[0]
const preexistingDocId = transaction.document_id ?? null
const inDialogDocId = uploadedDoc?.id ?? pickedDoc?.document_id ?? null
const currentDocId = preexistingDocId ?? inDialogDocId
const currentDocMime = preexistingDocId ? null : uploadedDoc?.file.type ?? null
const currentDocName = preexistingDocId
? null
: uploadedDoc?.fileName ?? pickedDoc?.file_name ?? null
return (
<Dialog open={open} onOpenChange={(o) => {
if (!o) {
setUploadedFiles([])
setPickedInboxDocs([])
setInboxPickerOpen(false)
}
onOpenChange(o)
}}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription className="sr-only">
{t('description')}
</DialogDescription>
</DialogHeader>
{/* Transaction summary */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'text-success'
: 'text-destructive'
}`}
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
{/* Underlag column: one coherent surface. Empty state sizes to its
content (dropzone + attached inbox-picker row) and top-aligns;
once a document exists the column takes the fixed viewer height
and stays sticky on desktop while the form scrolls. Stacks above
the form on smaller screens. */}
<div
className={
currentDocId
? 'flex h-[45vh] flex-col gap-3 lg:sticky lg:top-0 lg:h-[72vh] lg:self-start'
: 'flex flex-col gap-2 lg:sticky lg:top-0 lg:self-start'
}
>
{currentDocId ? (
<DocumentViewerPane
documentId={currentDocId}
mime={currentDocMime}
fileName={currentDocName}
className="min-h-0 flex-1"
/>
) : (
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
/>
)}
{/* Attach controls: only when the transaction has no pre-linked
document (a pre-linked one is already the verifikat's underlag). */}
{!preexistingDocId && (
currentDocId ? (
<div className="shrink-0 space-y-2">
{pickedInboxDocs.length > 0 && (
<div className="space-y-1">
{pickedInboxDocs.map((doc) => (
<div
key={doc.document_id}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded-sm bg-muted/50"
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate flex-1">
{doc.supplier_name ?? doc.file_name}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
aria-label={t('doc_picked_remove')}
onClick={() =>
setPickedInboxDocs((prev) =>
prev.filter((d) => d.document_id !== doc.document_id),
)
}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setInboxPickerOpen(true)}
>
<Inbox className="h-4 w-4 mr-2" />
{t('doc_pick_existing')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setUploadedFiles([])
setPickedInboxDocs([])
}}
>
<X className="h-3.5 w-3.5 mr-1.5" />
{t('doc_clear')}
</Button>
</div>
</div>
) : (
/* Secondary intake path, rendered as the dropzone's footer so
"drop a file" and "pick from the inbox" read as one surface. */
<button
type="button"
onClick={() => setInboxPickerOpen(true)}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-muted-foreground/25 px-3 py-2 text-[13px] text-muted-foreground transition-colors duration-150 hover:border-primary/50 hover:text-foreground"
>
<Inbox className="h-4 w-4" />
<span>{t('doc_pick_existing_inline')}</span>
</button>
)
)}
</div>
{/* Booking form */}
<div className="space-y-4">
{bankAccount !== null && (
<JournalEntryForm
key={`${transaction.id}-${proposalLines && proposalLines.length > 0 ? 'proposal' : preselectedTemplate?.id ?? 'default'}-${preselectedAccount ?? 'none'}-${bankAccount}`}
embedded
initialLines={
proposalLines && proposalLines.length > 0
? proposalLinesToFormLines(proposalLines, {
settlementAccount: bankAccount,
currency: transaction.currency,
foreignAmount: Math.abs(transaction.amount),
exchangeRate: transaction.exchange_rate,
})
: preselectedTemplate
? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount)
: buildInitialLines(transaction, bankAccountName ?? t('bank_line_description'), bankAccount, preselectedAccount)
}
initialDate={transaction.date}
initialDescription={transaction.description}
submitUrl={`/api/transactions/${transaction.id}/book`}
sourceType="bank_transaction"
sourceId={transaction.id}
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
duplicateMatchTransaction={{
id: transaction.id,
cash_account_id: transaction.cash_account_id ?? null,
currency: transaction.currency ?? 'SEK',
}}
// Duplicate guard match: the bank line was linked to an existing
// voucher (no new entry). Reuse the booked flow so attached
// documents land on that verifikat and the row leaves the list.
onDuplicateMatched={(journalEntryId) => handleBooked(transaction.id, journalEntryId, true)}
/>
)}
</div>
</div>
<InboxDocumentPicker
open={inboxPickerOpen}
onClose={() => setInboxPickerOpen(false)}
onSelect={(doc) =>
setPickedInboxDocs((prev) =>
prev.some((d) => d.document_id === doc.document_id) ? prev : [...prev, doc],
)
}
/>
</DialogContent>
</Dialog>
)
}