Files
accounted/components/transactions/SwipeCategorizationView.tsx
T
Jakob Wennberg cad83d180d fix(currency): transaction-date FX rate + mall bookings in SEK (#442) (#483)
* fix(currency): use transaction-date FX rate and post mall bookings in SEK (#442)

Three related defects in non-SEK transaction handling:

1. Ingest priced every transaction in a batch at today's rate because
   fetchMultipleRates() was called without a date. Switch to a per-
   (currency, date) fetch via fetchExchangeRate() + Promise.allSettled,
   so each row stores Riksbanken's rate as of its own transaction date.

2. buildMappingResultFromTemplate emitted VAT/reverse-charge/output-VAT
   lines from Math.abs(transaction.amount) — the source-currency amount.
   Mall bookings on USD/EUR transactions therefore posted the journal
   entry in the wrong currency. Resolve to SEK once via resolveSekAmount()
   and reuse for every line, matching what createTransactionJournalEntry
   already does for the settlement line.

3. The Granska bokföring modal and the journal-entry detail page lacked
   a clear audit trail. The verifikation preview now renders in SEK with
   a separate original-currency subtitle in the header; the detail page
   gains a dedicated "Valutaomräkning" card above Kontorader showing the
   rate and original total.

Also: replace truncate with break-all on the modal description so long
UUID-style strings (e.g. BALANCE_CASHBACK-…) wrap instead of forcing
horizontal overscroll.

Tests: 4 new booking-templates cases (USD 25%, EUR fallback via
exchange_rate, EU reverse-charge on USD, output VAT on USD income) and
2 new ingest cases (per-pair fetch dedup; no fetch for SEK rows).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(currency): wire SEK conversion into SwipeCategorizationView (#442 follow-up)

Address Greptile review:

- SwipeCategorizationView rendered JournalEntryPreview without amountSek,
  so the swipe flow had the same defect QuickReviewDialog had — a USD
  transaction showed the verifikation in source currency labeled SEK.
  Now resolves SEK once via resolveSekAmount, passes it to the preview,
  and shows SEK as the headline with the original-currency subtitle
  (parallel to QuickReviewDialog).
- Drop the now-dead `currency` prop from JournalEntryPreviewProps and
  its remaining call site in QuickReviewDialog — the component
  hardcodes 'SEK' on display, so the prop was misleading.
- Correct the misleading inline comment in ingest.ts: network failures
  resolve inside fetchExchangeRate to getFallbackRate() (non-null),
  so the key only stays unset on empty-observation responses or
  outright rejections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:00:12 +02:00

840 lines
31 KiB
TypeScript

'use client'
import { useState, useCallback, useEffect } from 'react'
import { motion, useMotionValue, useTransform, AnimatePresence, type PanInfo } from 'framer-motion'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library'
import TemplatePicker from './TemplatePicker'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionCategory, VatTreatment, BASAccount, EntityType } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
interface SwipeCategorizationViewProps {
transactions: TransactionWithInvoice[]
suggestions?: Record<string, SuggestedCategory[]>
templateSuggestions?: Record<string, SuggestedTemplate[]>
onCategorize: CategorizeHandler
onMatchInvoice?: MatchInvoiceHandler
onClose: () => void
entityType?: EntityType
}
const expenseCategories = EXPENSE_CATEGORIES
const incomeCategories = INCOME_CATEGORIES
export default function SwipeCategorizationView({
transactions,
suggestions,
templateSuggestions,
onCategorize,
onMatchInvoice,
onClose,
entityType,
}: SwipeCategorizationViewProps) {
const { toast } = useToast()
const [currentIndex, setCurrentIndex] = useState(0)
const [showCategorySelect, setShowCategorySelect] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
// Review step state
const [showReviewStep, setShowReviewStep] = useState(false)
const [pendingCategory, setPendingCategory] = useState<TransactionCategory | null>(null)
const [accountOverride, setAccountOverride] = useState('')
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null)
const [pendingTemplate, setPendingTemplate] = useState<BookingTemplate | null>(null)
const [pendingInboxItemId, setPendingInboxItemId] = useState<string | null>(null)
// Clear VAT treatment when switching to a liability/equity account (class 2)
useEffect(() => {
if (accountOverride.startsWith('2') && vatTreatment !== 'none') {
setVatTreatment('none')
}
}, [accountOverride]) // eslint-disable-line react-hooks/exhaustive-deps
// Fetch accounts on mount
useEffect(() => {
async function fetchAccounts() {
try {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
if (data) {
setAccounts(data)
}
} catch {
// Non-critical, AccountCombobox will just be empty
}
}
fetchAccounts()
}, [])
const currentTransaction = transactions[currentIndex]
const warnings = currentTransaction
? checkExpenseWarnings(currentTransaction.description)
: []
const x = useMotionValue(0)
const rotate = useTransform(x, [-200, 0, 200], [-15, 0, 15])
const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0.5, 1, 1, 1, 0.5])
const businessIndicatorOpacity = useTransform(x, [0, 100, 200], [0, 0.5, 1])
const skipIndicatorOpacity = useTransform(x, [-200, -100, 0], [1, 0.5, 0])
const moveToNext = useCallback(() => {
x.set(0)
if (currentIndex < transactions.length - 1) {
setCurrentIndex(currentIndex + 1)
} else {
onClose()
}
}, [x, currentIndex, transactions.length, onClose])
const handleDrag = useCallback(
(_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
if (isProcessing) return
x.set(info.offset.x)
},
[isProcessing, x]
)
const handleCategorySelect = useCallback((category: TransactionCategory) => {
// Set up review step with defaults for this category
const defaultAccount = getDefaultAccountForCategory(category)
const defaultVat = getDefaultVatTreatmentForCategory(category)
setPendingCategory(category)
setAccountOverride(defaultAccount)
setVatTreatment(defaultVat ?? 'none')
setPendingTemplateId(null)
setPendingTemplate(null)
setPendingInboxItemId(null)
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
}, [])
const handlePickerTemplateSelect = useCallback((template: BookingTemplate) => {
setPendingCategory(template.fallback_category)
setAccountOverride(template.debit_account)
setVatTreatment(template.vat_treatment ?? 'none')
// Library templates aren't in the static registry the backend validates against,
// so we only send the ID for static templates. The pre-filled account/VAT drive
// the booking for library templates.
setPendingTemplateId(isLibraryTemplateId(template.id) ? null : template.id)
setPendingTemplate(template)
setPendingInboxItemId(null)
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
}, [])
const handleTemplateSelect = useCallback((templateId: string, inboxItemId?: string) => {
const template = getTemplateById(templateId)
if (!template) return
setPendingCategory(template.fallback_category)
setAccountOverride(template.debit_account)
setVatTreatment(template.vat_treatment ?? 'none')
setPendingTemplateId(templateId)
setPendingTemplate(template)
setPendingInboxItemId(inboxItemId ?? null)
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
}, [])
const handleDragEnd = useCallback(
async (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
if (isProcessing || !currentTransaction) return
const mx = info.offset.x
const vx = Math.abs(info.velocity.x)
const shouldSwipe = Math.abs(mx) > 100 || vx > 500
if (shouldSwipe) {
if (mx > 0) {
// Swipe right = categorize as business
if (currentTransaction.amount < 0) {
// Show category selector for business expenses
setShowCategorySelect(true)
x.set(0)
} else {
// Income: go to review step with income_other default
handleCategorySelect('income_other')
x.set(0)
}
} else {
// Swipe left = skip
moveToNext()
}
} else {
x.set(0)
}
},
[isProcessing, currentTransaction, handleCategorySelect, x, moveToNext]
)
const resetUploadState = useCallback(() => {
setUploadedFiles([])
setShowUploadZone(false)
}, [])
const handleReviewConfirm = async () => {
if (!pendingCategory) return
setIsProcessing(true)
setError(null)
try {
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
const defaultAccount = getDefaultAccountForCategory(pendingCategory)
// Only send override if it differs from the default
const override = accountOverride && accountOverride !== defaultAccount
? accountOverride
: undefined
const journalEntryId = await onCategorize(
currentTransaction.id,
true,
pendingCategory,
resolvedVat,
override,
pendingTemplateId ?? undefined,
pendingInboxItemId ?? undefined
)
if (journalEntryId) {
// Link uploaded documents to the journal entry
if (uploadedFiles.length > 0) {
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
let linkFailCount = 0
for (const file of filesToLink) {
try {
await fetch(`/api/documents/${file.id}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ journal_entry_id: journalEntryId }),
})
} catch {
linkFailCount++
}
}
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen.`,
variant: 'destructive',
})
}
}
resetUploadState()
setShowReviewStep(false)
setPendingCategory(null)
setPendingTemplateId(null)
setPendingTemplate(null)
setPendingInboxItemId(null)
moveToNext()
} else {
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
}
} catch {
setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.')
} finally {
setIsProcessing(false)
}
}
const handleMatchInvoice = async () => {
if (!currentTransaction.potential_invoice || !onMatchInvoice) return
setIsProcessing(true)
setError(null)
try {
const success = await onMatchInvoice(
currentTransaction.id,
currentTransaction.potential_invoice.id
)
if (success) {
moveToNext()
} else {
setError('Kunde inte matcha faktura. Tryck "Hoppa över" för att gå vidare.')
}
} catch {
setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.')
} finally {
setIsProcessing(false)
}
}
const handleSkip = useCallback(() => {
setError(null)
setShowCategorySelect(false)
setShowReviewStep(false)
setPendingCategory(null)
setPendingTemplateId(null)
setPendingInboxItemId(null)
resetUploadState()
moveToNext()
}, [moveToNext, resetUploadState])
if (!currentTransaction) {
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col items-center justify-center">
<div className="text-center">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4">
<Check className="h-8 w-8 text-success" />
</div>
<h2 className="font-display text-2xl font-medium">Klart!</h2>
<p className="text-muted-foreground mt-2">
Alla transaktioner är nu bokförda
</p>
<Button onClick={onClose} className="mt-6">
Tillbaka till transaktioner
</Button>
</div>
</div>
)
}
if (showCategorySelect) {
const direction = currentTransaction.amount > 0 ? 'income' : 'expense'
const txSuggestions = templateSuggestions?.[currentTransaction.id]
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<Button variant="ghost" size="icon" onClick={() => setShowCategorySelect(false)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Välj mall</h1>
<div className="w-10" />
</div>
<Card className="mx-4 mt-3">
<CardContent className="pt-4">
<p className="font-medium">{currentTransaction.description}</p>
<p className="font-display text-2xl font-medium tabular-nums mt-2">
{formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
</p>
</CardContent>
</Card>
{error && (
<div className="mx-4 mt-2 p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
<div className="flex-1 overflow-hidden mt-2">
<TemplatePicker
direction={direction}
entityType={entityType}
suggestedTemplates={txSuggestions}
onSelect={handlePickerTemplateSelect}
selectedTemplateId={pendingTemplate?.id ?? pendingTemplateId ?? undefined}
/>
</div>
<div className="p-4 border-t">
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={handleSkip}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
</Button>
</div>
</div>
)
}
if (showReviewStep && pendingCategory) {
const categoryLabel = [...expenseCategories, ...incomeCategories].find(
(c) => c.value === pendingCategory
)?.label || pendingCategory
const selectedTemplate = pendingTemplate
// Auto-clear VAT when a class 2 (liability/equity) account is selected
const isLiabilityAccount = accountOverride.startsWith('2')
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<Button
variant="ghost"
size="icon"
onClick={() => {
setShowReviewStep(false)
setShowCategorySelect(true)
}}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Granska bokföring</h1>
<div className="w-10" />
</div>
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Transaction summary */}
{(() => {
const reviewSekAmount = resolveSekAmount(
currentTransaction.amount,
currentTransaction.amount_sek,
currentTransaction.currency,
currentTransaction.exchange_rate
)
const isForeign = !!(currentTransaction.currency && currentTransaction.currency !== 'SEK')
return (
<Card>
<CardContent className="pt-4 space-y-1">
<p className="font-medium break-all">{currentTransaction.description}</p>
<p className="text-sm text-muted-foreground">{formatDate(currentTransaction.date)}</p>
<p className="font-display text-2xl font-medium tabular-nums mt-2">
{currentTransaction.amount > 0 ? '+' : ''}
{formatCurrency(reviewSekAmount, 'SEK')}
</p>
{isForeign && (
<p className="text-xs text-muted-foreground tabular-nums">
{currentTransaction.amount > 0 ? '+' : ''}
{formatCurrency(currentTransaction.amount, currentTransaction.currency)}
</p>
)}
</CardContent>
</Card>
)
})()}
{/* Selected template or category */}
<div>
<label className="text-sm font-medium text-muted-foreground">
{selectedTemplate ? 'Mall' : 'Kategori'}
</label>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="text-sm py-1 px-3">
{selectedTemplate ? selectedTemplate.name_sv : categoryLabel}
</Badge>
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => {
setShowReviewStep(false)
setShowCategorySelect(true)
}}
>
Byt mall
</button>
</div>
{selectedTemplate && (
<p className="mt-1.5 text-xs font-mono text-muted-foreground">
D: {formatAccountWithName(selectedTemplate.debit_account)} K: {formatAccountWithName(selectedTemplate.credit_account)}
</p>
)}
</div>
{/* Template special rules warning */}
{selectedTemplate?.special_rules_sv && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2.5">
<p className="text-xs text-warning-foreground leading-snug">
{selectedTemplate.special_rules_sv}
</p>
</div>
)}
{/* Deductibility note */}
{selectedTemplate?.deductibility_note_sv && (
<div className="rounded-lg border border-primary/20 bg-primary/[0.03] px-3 py-2.5">
<p className="text-xs text-foreground leading-snug">
{selectedTemplate.deductibility_note_sv}
</p>
</div>
)}
{/* Reverse charge VAT registration warning */}
{selectedTemplate?.requires_vat_registration_data && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2.5">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
<p className="text-xs text-warning-foreground leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
</p>
</div>
</div>
)}
{/* Journal entry preview */}
<JournalEntryPreview
amount={currentTransaction.amount}
amountSek={resolveSekAmount(
currentTransaction.amount,
currentTransaction.amount_sek,
currentTransaction.currency,
currentTransaction.exchange_rate
)}
category={pendingCategory}
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
accountOverride={accountOverride}
entityType={entityType}
/>
{/* Account override */}
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
<div className="mt-1">
<AccountCombobox
value={accountOverride}
accounts={accounts}
onChange={setAccountOverride}
/>
</div>
</div>
{/* VAT treatment */}
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms för skuld-/eget kapital-konton
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
value={vatTreatment}
onValueChange={setVatTreatment}
/>
) : (
<p className="text-sm">
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
{' '}
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
Ändra
</button>
</p>
)}
</div>
</div>
{/* Document upload */}
{(
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowUploadZone(!showUploadZone)}
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Underlag</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
</span>
)}
</div>
{showUploadZone ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{showUploadZone && (
<div className="px-3 pb-3">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
/>
</div>
)}
</div>
)}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
</div>
{/* Actions */}
<div className="p-4 border-t space-y-2">
<Button
className="w-full"
onClick={handleReviewConfirm}
disabled={isProcessing || !accountOverride}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokför...' : 'Bokför'}
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={handleSkip}
disabled={isProcessing}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
</Button>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col" style={{ paddingTop: 'env(safe-area-inset-top, 0px)', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
<div className="text-center">
<p className="text-sm text-muted-foreground">
{currentIndex + 1} av {transactions.length}
</p>
</div>
<div className="w-10" />
</div>
{/* Instructions */}
<div className="flex justify-between px-8 py-4 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<ArrowLeft className="h-4 w-4" />
<SkipForward className="h-4 w-4" />
<span>Hoppa över</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span>Bokför</span>
<Building className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</div>
</div>
{/* Card stack */}
<div className="flex-1 flex items-center justify-center px-4 relative">
{/* Swipe indicators */}
<motion.div
className="absolute left-8 flex items-center gap-2 text-muted-foreground"
style={{ opacity: skipIndicatorOpacity }}
>
<SkipForward className="h-8 w-8" />
<span className="font-semibold">Hoppa över</span>
</motion.div>
<motion.div
className="absolute right-8 flex items-center gap-2 text-success"
style={{ opacity: businessIndicatorOpacity }}
>
<span className="font-semibold">Företag</span>
<Building className="h-8 w-8" />
</motion.div>
<AnimatePresence>
<motion.div
key={currentTransaction.id}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={1}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
style={{ x, rotate, opacity }}
className="w-full max-w-sm touch-none cursor-grab active:cursor-grabbing"
>
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<Badge variant="outline">{formatDate(currentTransaction.date)}</Badge>
<div className="flex items-center gap-2">
{currentTransaction.receipt_id && (
<Badge variant="secondary" className="gap-1">
<ReceiptIcon className="h-3 w-3" />
Kvitto
</Badge>
)}
{currentTransaction.document_id && (
<Badge variant="secondary" className="gap-1">
<Paperclip className="h-3 w-3" />
Bilaga
</Badge>
)}
<Badge>{currentTransaction.currency}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-lg font-medium">{currentTransaction.description}</p>
<p
className={`text-3xl font-bold ${
currentTransaction.amount > 0 ? 'text-success' : ''
}`}
>
{currentTransaction.amount > 0 ? '+' : ''}
{formatCurrency(currentTransaction.amount, currentTransaction.currency)}
</p>
{/* Potential Invoice Match */}
{currentTransaction.potential_invoice && (
<div className="p-4 rounded-lg border-2 border-success/40 bg-success/5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-success">
<FileText className="h-5 w-5" />
<span className="font-semibold text-sm">Fakturamatchning hittad</span>
</div>
<Badge variant="outline" className="text-success border-success">
Match
</Badge>
</div>
<div className="text-sm">
<p className="font-medium">
Faktura {currentTransaction.potential_invoice.invoice_number}
</p>
<p className="text-muted-foreground">
{currentTransaction.potential_invoice.customer?.name || 'Okänd kund'}
</p>
<p className="font-medium text-success">
{formatCurrency(
currentTransaction.potential_invoice.total,
currentTransaction.potential_invoice.currency
)}
</p>
</div>
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="space-y-2 pt-4 border-t">
{warnings.map((warning, idx) => (
<div
key={idx}
className={`flex items-start gap-2 p-2 rounded text-sm ${
warning.warningLevel === 'danger'
? 'bg-destructive/10 text-destructive'
: warning.warningLevel === 'warning'
? 'bg-warning/10 text-warning-foreground'
: 'bg-muted'
}`}
>
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">{warning.category}</p>
<p className="text-xs opacity-90">{warning.message}</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</motion.div>
</AnimatePresence>
</div>
{/* Action buttons */}
<div className="p-4 border-t">
<div className="flex flex-col gap-3 max-w-sm mx-auto">
{/* Error message */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Invoice match button - primary action when there's a match */}
{currentTransaction.potential_invoice && onMatchInvoice && (
<Button
className="w-full bg-success hover:bg-success/90 text-success-foreground"
onClick={handleMatchInvoice}
disabled={isProcessing}
>
<Link2 className="mr-2 h-4 w-4" />
Matcha med Faktura {currentTransaction.potential_invoice.invoice_number}
</Button>
)}
{/* Suggested categories - shown as quick-select buttons */}
{suggestions && suggestions[currentTransaction.id] && suggestions[currentTransaction.id].length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground text-center">Föreslagna kategorier</p>
<div className="grid grid-cols-2 gap-2">
{suggestions[currentTransaction.id].map((suggestion) => (
<Button
key={suggestion.category}
variant="outline"
className="h-auto py-2.5 px-3 text-left justify-start border-primary/30 hover:border-primary hover:bg-primary/5"
onClick={() => handleCategorySelect(suggestion.category)}
disabled={isProcessing}
>
<div className="flex flex-col">
<span className="text-sm font-medium">{suggestion.label}</span>
{suggestion.account && (
<span className="text-xs text-muted-foreground">{formatAccountWithName(suggestion.account)}</span>
)}
</div>
</Button>
))}
</div>
</div>
)}
{/* Categorization button */}
<Button
className="w-full"
onClick={() => {
if (currentTransaction.amount < 0) {
setShowCategorySelect(true)
} else {
handleCategorySelect('income_other')
}
}}
disabled={isProcessing}
>
<Building className="mr-2 h-4 w-4" />
Bokför
</Button>
{/* Skip button - always visible */}
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={handleSkip}
disabled={isProcessing}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
</Button>
</div>
</div>
</div>
)
}