Files
accounted/components/transactions/DuplicateBookingDialog.tsx
T
Jakob Wennberg e51a2c8102 refactor(design): no amber boxes, attention is one ochre sentence (#1562)
The founder wants the yellow boxes gone everywhere. The design system
already agreed: status colors are data, not chrome (convention 12) and
attention is a single ochre sentence, never a banner (convention 6).
This enforces it:

- ConfirmationDialog: the amber warning panel is now an AttnLine, and
  the hardcoded Swedish default warningText is gone: it injected an
  immutability warning into dialogs whose authors never asked for one
  (every current caller passes the prop explicitly, so no behavior
  change at any call site).
- Badge warning variant: amber fill replaced with a hairline chip and
  ochre text.
- DestructiveConfirmDialog warning variant: neutral icon disc, default
  primary confirm button (only --destructive survives as chrome).
- BankSyncStatusChip stale state: same neutral shape as the healthy
  chip, ochre text carries the signal.
- SandboxBanner: solid amber bar becomes secondary-on-border chrome.
- BankIdAuth, BankIdCompanyPicker, SessionTimeoutModal: the last three
  raw-amber (bg-amber-*) holdouts moved onto tokens, the company-picker
  banner becoming a plain AttnLine.
- Mechanical sweep of the ~58 hand-rolled bg-warning/border-warning
  boxes across 45 files: fills to bg-muted/30 (icon discs bg-muted),
  borders to border-border, text-warning-foreground to text-attn. The
  account-class dots in account-number.tsx keep bg-warning: they are
  data indicators, not chrome.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:20:16 +02:00

260 lines
12 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useTranslations, useLocale } from 'next-intl'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
import type { CashAccount } from '@/types'
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
/** The bank transaction being booked, as much as the caller knows about it.
* Enables the "Matcha mot verifikatet" action for ledger-only candidates. */
export interface DuplicateMatchTransaction {
id: string
cash_account_id?: string | null
currency?: string | null
}
/**
* Soft warning shown when the booking-time duplicate guard fires
* (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): another already-booked transaction
* shares this one's date + amount + bank account. Never a hard block:
* genuinely repeated same-day payments (e.g. several identical Swish transfers)
* are legitimate, so the user can review the existing verifikat or book anyway.
*
* Shared by the /transactions list (runCategorize) and the manual booking
* dialog (JournalEntryForm → /api/transactions/[id]/book). The caller owns the
* retry: "Bokför ändå" must re-issue the request with force=true bound to
* `candidate.journal_entry_id` via `expected_duplicate_journal_entry_id`: it
* is present on both candidate kinds (a sibling-transaction candidate and a
* ledger-only voucher candidate, which has no transaction_id), and the server
* re-detects it so a stale id can't wave the guard away.
*
* Ledger-only candidates (candidate.transaction_id === null: the voucher exists
* but no bank transaction is linked to it, e.g. a verifikat from an SIE import
* or an invoice marked paid) get "Matcha mot verifikatet" as the PRIMARY action
* when the caller supplies `matchTransaction` + `onMatched`: it links the bank
* line to the existing voucher via /api/reconciliation/bank/link (the same path
* MatchVoucherDialog uses) instead of double-booking the affärshändelse.
* "Bokför ändå" stays available but demoted. Sibling-transaction candidates
* keep booking as the primary action: matching a second bank line onto a
* voucher that already has one is the N:1 edge case, not the default.
*/
export default function DuplicateBookingDialog({
candidate,
processing = false,
onBookAnyway,
onCancel,
matchTransaction,
onMatched,
}: {
/** The already-booked sibling, or null to keep the dialog closed. */
candidate: BookedDuplicateCandidate | null
processing?: boolean
onBookAnyway: () => void
onCancel: () => void
/** The transaction being booked; required for the match action. */
matchTransaction?: DuplicateMatchTransaction | null
/** Called after /api/reconciliation/bank/link succeeds. Mirrors
* MatchVoucherDialog's onLinked signature: everything the refresh needs is
* passed in, so a mid-request dialog close can't strand the update. The
* caller owns the success toast and state refresh (and closes the dialog by
* clearing `candidate`). */
onMatched?: (transactionId: string, journalEntryId: string, voucherLabel: string) => void
}) {
const t = useTranslations('transactions')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const [matching, setMatching] = useState(false)
// The match action is offered only for ledger-only voucher candidates: the
// voucher has no bank transaction linked yet, so linking THIS one to it is
// the right default (one affärshändelse, one verifikat).
const canMatch =
candidate !== null && candidate.transaction_id === null && !!matchTransaction && !!onMatched
async function handleMatch() {
if (!candidate || !matchTransaction || !onMatched || matching) return
setMatching(true)
try {
// Link on the exact 19xx account the candidate voucher was booked to
// when the guard reported it: a legacy transaction without a
// cash_account_id would otherwise resolve by currency and can pick a
// different 19xx than the voucher's leg, dead-ending the link. Fall back
// to resolving from the company's cash accounts, same as
// MatchVoucherDialog: the link route validates the voucher has a leg on
// this account and that the transaction belongs to it.
let account = candidate.account_number ?? '1930'
if (!candidate.account_number) {
try {
const caRes = await fetch('/api/cash-accounts')
if (caRes.ok) {
const caJson = await caRes.json()
const accounts = (caJson.data ?? []) as CashAccount[]
account = resolveAccount(
accounts,
matchTransaction.cash_account_id ?? null,
matchTransaction.currency ?? 'SEK',
).account
}
} catch {
// Network hiccup: fall back to 1930; the link route re-validates.
}
}
const res = await fetch('/api/reconciliation/bank/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transaction_id: matchTransaction.id,
journal_entry_id: candidate.journal_entry_id,
account_number: account,
}),
})
const result = await res.json()
if (!res.ok || result.error) {
toast({
title: t('dialog_duplicate_match_failed'),
description: getErrorMessage(result, { context: 'transaction', statusCode: res.status, locale }),
variant: 'destructive',
})
return
}
onMatched(matchTransaction.id, candidate.journal_entry_id, candidate.voucher_label)
} catch {
toast({
title: t('dialog_duplicate_match_failed'),
description: getErrorMessage(null, { context: 'transaction', locale }),
variant: 'destructive',
})
} finally {
setMatching(false)
}
}
const busy = processing || matching
return (
<Dialog
open={candidate !== null}
onOpenChange={(open) => {
if (!open) onCancel()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('dialog_duplicate_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('dialog_duplicate_body')}</p>
{candidate && (
<div className="space-y-1 rounded-md border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">
{candidate.voucher_label
? t('dialog_duplicate_voucher_label', { label: candidate.voucher_label })
: t('dialog_duplicate_voucher_generic')}
</span>
{/* candidate.amount is ALWAYS SEK or null, never a foreign
number (booking-duplicate-detection.ts): the explicit 'SEK'
states that contract at the call site. When the sibling is
foreign and carries no stored rate, amount is null and the
honest figure is the sibling's own amount in its own
currency, explicitly labelled. */}
<span className="tabular-nums">
{candidate.amount != null
? formatCurrency(candidate.amount, 'SEK')
: candidate.currency && candidate.amount_in_currency != null
? formatCurrency(candidate.amount_in_currency, candidate.currency)
: t('dialog_duplicate_amount_unknown')}
</span>
</div>
{/* Verified FX sibling: the kr figure above is the sibling's own
booked SEK value; show the foreign original beneath it so the
user can recognise their EUR/USD line at a glance. */}
{candidate.amount != null &&
candidate.currency &&
candidate.amount_in_currency != null && (
<div className="text-right text-xs text-muted-foreground tabular-nums">
{formatCurrency(candidate.amount_in_currency, candidate.currency)}
</div>
)}
<div className="text-xs text-muted-foreground tabular-nums">
{formatDate(candidate.entry_date)}
</div>
{candidate.description && (
<div className="truncate text-xs text-muted-foreground">{candidate.description}</div>
)}
{/* Rateless foreign sibling: the amounts matched exactly in the
shared currency, but no SEK value exists for it, so say that
instead of printing an authoritative-looking kr figure.
Gated on candidate.currency: the sentence names the currency,
and interpolating an empty string renders broken Swedish
("samma belopp i , men..."). A null-currency candidate with a
null amount already shows dialog_duplicate_amount_unknown. */}
{candidate.amount == null && candidate.currency && (
<div className="flex items-start gap-2 pt-1">
<AlertTriangle className="h-3.5 w-3.5 text-attn flex-shrink-0 mt-0.5" />
<p className="text-xs text-attn leading-snug">
{t('dialog_duplicate_sek_unavailable', { currency: candidate.currency })}
</p>
</div>
)}
{/* Ledger-only candidate found for a bank line that has no SEK
value: the kr figure above is the voucher leg's real amount,
but it could not be compared against the transaction, so the
match rests on date + account + direction alone. */}
{candidate.amount != null && !candidate.amount_verified && (
<div className="flex items-start gap-2 pt-1">
<AlertTriangle className="h-3.5 w-3.5 text-attn flex-shrink-0 mt-0.5" />
<p className="text-xs text-attn leading-snug">
{t('dialog_duplicate_amount_unverified')}
</p>
</div>
)}
</div>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
{candidate && (
<Button asChild variant="ghost" size="sm" className="text-muted-foreground sm:mr-auto">
<a
href={`/bookkeeping/${candidate.journal_entry_id}`}
target="_blank"
rel="noopener noreferrer"
>
{t('dialog_duplicate_view_voucher')}
</a>
</Button>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={onCancel} disabled={busy}>
{t('dialog_duplicate_cancel')}
</Button>
{canMatch ? (
<>
<Button variant="outline" onClick={onBookAnyway} disabled={busy}>
{t('dialog_duplicate_book_anyway')}
</Button>
<Button onClick={handleMatch} disabled={busy}>
{matching && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('dialog_duplicate_match')}
</Button>
</>
) : (
<Button onClick={onBookAnyway} disabled={busy}>
{t('dialog_duplicate_book_anyway')}
</Button>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}