Files
accounted/components/transactions/TransactionBookingDialog.tsx
T
Mattsson c981384a0e fix(transactions): keep the assistant usable through the whole booking flow (#1975)
* fix(transactions): keep the assistant usable through the whole booking flow

Three gaps around the agent sheet during booking on Transaktioner:

- The AgentTrigger bubble lived inside #dash-shell, so the inert that
  non-modal dialogs set on the shell made the assistant impossible to
  OPEN once a booking dialog was up. Moved outside the shell (it is
  position: fixed) and raised to z-[45]: above the DialogVeil (z-40) so
  it stays clickable, below dialog content (z-50). Under true modal
  dialogs Radix's body pointer-events lock keeps it dead as before.
- Wide dialogs centered on the full viewport while the docked sheet
  (z-60) covered their right edge, hiding e.g. the Granska button.
  DialogContent now centers in the space left of --agent-dock-w, and
  the wide booking/review variants cap their width against it.
- Step 1 of the flow (template picker, QuickReviewDialog) was still
  fully modal, so the assistant was dead there. Both are now non-modal
  with DialogVeil + a shared ref-counted useDashShellInert hook (also
  replacing the duplicated inert effects in TransactionBookingDialog
  and NewInvoiceDialog). Their Esc/veil-click dismissal is kept: they
  hold no half-filled form. Clicks in the agent sheet or its trigger
  never dismiss any dialog: data-agent-ui counts as inside, same
  mechanism as data-dialog-companion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): dialog centering reads a docked-only sheet width variable

Skeptic review refuted the first cut: globals.css seeds --agent-dock-w
at the 10px frame gutter on :root by design, so the 0px fallback in the
new dialog centering never applied and every dialog sat 5px left of
center (full-bleed dialogs clipped 5px off-screen).

Introduce --agent-sheet-w, set inline by AgentSheetProvider only while
the sheet is docked and removed otherwise, so the 0px fallback is real:
sheet closed or floating renders byte-identical to the old left-[50%]
and old max widths. Also cap the template picker and QuickReview's
narrow variant, which could clip off-screen left on narrow desktops
with the sheet docked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 14:22:20 +02:00

461 lines
19 KiB
TypeScript

'use client'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogVeil, useDashShellInert } 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])
// Non-modal dialog (see below): page modality is restored by hand so the
// agent sheet stays live. See useDashShellInert in components/ui/dialog.tsx.
useDashShellInert(open)
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)
}} modal={false}>
<DialogVeil />
<DialogContent
// Width caps at the space left of a docked agent sheet so the form's
// right edge, and the Granska button, never end up unreachable under
// the sheet (z-60 over z-50). --agent-sheet-w is docked-only, so with
// the sheet closed this is exactly the old max-w-6xl.
className="max-w-[min(72rem,calc(100vw-var(--agent-sheet-w,0px)))] max-h-[90vh] overflow-y-auto"
// Non-modal so the agent sheet (fixed z-[60], portaled outside this
// dialog) stays interactive beside a booking in progress; a click in
// its text field must not count as outside-dismissal. A half-booked
// transaction must also survive a stray Escape or backdrop click.
// Closing is explicit: the header X. Same convention as
// NewInvoiceDialog (which pairs non-modality with the inert effect
// above).
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<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>
)
}