e51a2c8102
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>
260 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|