Files
accounted/app/(dashboard)/transactions/page.tsx
T
Jakob Wennberg f3ec634a46 feat: open-source under AGPL-3.0, redesign UI to grayscale palette, add uncategorize API, fix VAT account names
Add LICENSE (AGPL-3.0-or-later), CONTRIBUTING.md, SECURITY.md, DCO, and NOTICE files.
Rewrite README for open-source audience with self-hosting instructions.
Redesign color palette to grayscale chrome theme across all components.
Add transaction uncategorize API route with tests.
Fix VAT account name mismatches in migration 052.
Improve import page with SIE file support and loading skeleton.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:18:11 +01:00

820 lines
30 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { AnimatePresence } from 'framer-motion'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { X } from 'lucide-react'
import TransactionForm from '@/components/transactions/TransactionForm'
import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView'
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
import TransactionInboxCard from '@/components/transactions/TransactionInboxCard'
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState'
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
import DescribeTransactionDialog from '@/components/transactions/DescribeTransactionDialog'
import TemplatePicker from '@/components/transactions/TemplatePicker'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
export default function TransactionsPage() {
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [mode, setMode] = useState<ViewMode>('inbox')
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [showSwipeView, setShowSwipeView] = useState(false)
const [categorySuggestions, setCategorySuggestions] = useState<Record<string, SuggestedCategory[]>>({})
const [templateSuggestions, setTemplateSuggestions] = useState<Record<string, SuggestedTemplate[]>>({})
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false)
const [processingId, setProcessingId] = useState<string | null>(null)
// Batch mode
const [isBatchMode, setIsBatchMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchSelector, setShowBatchSelector] = useState(false)
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
// Invoice match dialog
const [matchDialogOpen, setMatchDialogOpen] = useState(false)
const [selectedTransaction, setSelectedTransaction] = useState<TransactionWithInvoice | null>(null)
const [isConfirmingMatch, setIsConfirmingMatch] = useState(false)
// Booking dialog (journal entry form)
const [bookingDialogOpen, setBookingDialogOpen] = useState(false)
const [bookingDialogTransaction, setBookingDialogTransaction] = useState<TransactionWithInvoice | null>(null)
// Template picker dialog
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [templatePickerTransaction, setTemplatePickerTransaction] = useState<TransactionWithInvoice | null>(null)
// Quick review dialog (suggestion review before booking)
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
const [quickReviewTransaction, setQuickReviewTransaction] = useState<TransactionWithInvoice | null>(null)
const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null)
const [quickReviewLabel, setQuickReviewLabel] = useState('')
const [quickReviewTemplate, setQuickReviewTemplate] = useState<BookingTemplate | null>(null)
const [quickReviewTemplateId, setQuickReviewTemplateId] = useState<string | undefined>(undefined)
// Describe dialog
const [describeDialogOpen, setDescribeDialogOpen] = useState(false)
const [describeDialogTransaction, setDescribeDialogTransaction] = useState<TransactionWithInvoice | null>(null)
// Entity type for tooltip context
const [entityType, setEntityType] = useState<string>('enskild_firma')
// Set of transaction IDs that are animating out (just categorized)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
const { toast } = useToast()
const supabase = createClient()
// Computed lists
const uncategorizedTransactions = transactions
.filter((t) => t.is_business === null && !exitingIds.has(t.id))
.sort((a, b) => {
const aHasMatch = a.potential_invoice ? 1 : 0
const bHasMatch = b.potential_invoice ? 1 : 0
if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
return b.date.localeCompare(a.date)
})
const transactionsWithMatches = transactions.filter((t) => t.potential_invoice && !t.invoice_id)
async function fetchTransactions() {
setIsLoading(true)
const { data: txData, error: txError } = await supabase
.from('transactions')
.select('*')
.order('date', { ascending: false })
if (txError) {
toast({ title: 'Fel', description: 'Kunde inte hämta transaktioner', variant: 'destructive' })
setIsLoading(false)
return
}
const potentialInvoiceIds = (txData || [])
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
if (potentialInvoiceIds.length > 0) {
const { data: invoices } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.in('id', potentialInvoiceIds)
if (invoices) {
invoiceMap = invoices.reduce((acc, inv) => {
acc[inv.id] = inv
return acc
}, {} as Record<string, Invoice & { customer?: Customer }>)
}
}
// Fetch matched inbox items for unbooked transactions
const unbookedTxIds = (txData || [])
.filter((t) => !t.journal_entry_id && t.is_business === null)
.map((t) => t.id)
let inboxItemMap: Record<string, InvoiceInboxItem> = {}
if (unbookedTxIds.length > 0) {
const { data: inboxItems } = await supabase
.from('invoice_inbox_items')
.select('*')
.in('matched_transaction_id', unbookedTxIds)
.in('status', ['ready', 'processing'])
if (inboxItems) {
inboxItemMap = inboxItems.reduce((acc, item) => {
if (item.matched_transaction_id) {
acc[item.matched_transaction_id] = item as InvoiceInboxItem
}
return acc
}, {} as Record<string, InvoiceInboxItem>)
}
}
const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
matched_inbox_item: inboxItemMap[t.id] || undefined,
}))
setTransactions(transactionsWithInvoices)
setIsLoading(false)
}
async function fetchCategorySuggestions(txIds: string[]) {
if (txIds.length === 0) return
setIsLoadingSuggestions(true)
try {
const response = await fetch('/api/transactions/suggest-categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_ids: txIds }),
})
const data = await response.json()
if (data.suggestions) {
setCategorySuggestions(data.suggestions)
}
if (data.template_suggestions) {
setTemplateSuggestions(data.template_suggestions)
}
} catch {
// Non-critical
}
setIsLoadingSuggestions(false)
}
// Fetch transactions on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchTransactions() }, [])
// Fetch entity type for tooltip context
useEffect(() => {
async function fetchEntityType() {
try {
const res = await fetch('/api/settings')
const data = await res.json()
if (data?.entity_type) {
setEntityType(data.entity_type)
}
} catch {
// Non-critical, defaults to enskild_firma
}
}
fetchEntityType()
}, [])
// Auto-fetch suggestions when transactions load
useEffect(() => {
const uncatIds = transactions
.filter((t) => t.is_business === null)
.map((t) => t.id)
.slice(0, 50)
if (uncatIds.length > 0) {
fetchCategorySuggestions(uncatIds)
}
}, [transactions.length])
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => {
try {
setProcessingId(id)
const response = await fetch(`/api/transactions/${id}/categorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
is_business: isBusiness,
category,
vat_treatment: vatTreatment,
account_override: accountOverride,
template_id: templateId,
inbox_item_id: inboxItemId,
}),
})
const result = await response.json()
if (!response.ok) {
toast({ title: 'Fel', description: result.error || 'Kunde inte uppdatera transaktion', variant: 'destructive' })
setProcessingId(null)
return null
}
// Mark as exiting for animation, then update state
setExitingIds((prev) => new Set(prev).add(id))
if (result.journal_entry_created) {
toast({
title: 'Bokförd',
description: 'Transaktion bokförd och verifikation skapad',
action: (
<ToastAction altText="Ångra kategorisering" onClick={async () => {
try {
const undoRes = await fetch(`/api/transactions/${id}/uncategorize`, { method: 'POST' })
if (undoRes.ok) {
setTransactions((prev) =>
prev.map((t) =>
t.id === id
? { ...t, is_business: null, category: null as unknown as TransactionCategory, journal_entry_id: null }
: t
)
)
toast({ title: 'Ångrad', description: 'Kategorisering har ångrats' })
} else {
const errData = await undoRes.json()
toast({ title: 'Kunde inte ångra', description: errData.error || 'Något gick fel', variant: 'destructive' })
}
} catch {
toast({ title: 'Kunde inte ångra', description: 'Något gick fel', variant: 'destructive' })
}
}}>
Ångra
</ToastAction>
),
})
} else if (result.journal_entry_error) {
toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' })
} else {
toast({ title: 'Delvis bokförd', description: 'Transaktion uppdaterad men verifikation kunde inte skapas' })
}
// Update transaction in state after a brief delay for animation
setExitingIds((prev) => new Set(prev).add(id))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === id
? { ...t, is_business: isBusiness, category: result.category, journal_entry_id: result.journal_entry_id }
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
setProcessingId(null)
}, 350)
return result.journal_entry_id || null
} catch {
toast({ title: 'Fel', description: 'Något gick fel vid bokföring', variant: 'destructive' })
setProcessingId(null)
return null
}
}
async function handleMarkPrivate(id: string) {
await handleCategorize(id, false, 'private')
}
async function handleConfirmInvoiceMatch() {
if (!selectedTransaction?.potential_invoice) return
setIsConfirmingMatch(true)
try {
const response = await fetch(`/api/transactions/${selectedTransaction.id}/match-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: selectedTransaction.potential_invoice.id }),
})
const result = await response.json()
if (!response.ok) {
toast({ title: 'Fel', description: result.error || 'Kunde inte matcha faktura', variant: 'destructive' })
setIsConfirmingMatch(false)
return
}
toast({
title: 'Faktura matchad',
description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`,
})
setMatchDialogOpen(false)
// Mark as exiting for animation
setExitingIds((prev) => new Set(prev).add(selectedTransaction.id))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === selectedTransaction.id
? {
...t,
invoice_id: selectedTransaction.potential_invoice?.id || null,
potential_invoice_id: null,
potential_invoice: undefined,
is_business: true,
category: 'income_services' as TransactionCategory,
journal_entry_id: result.journal_entry_id,
}
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(selectedTransaction.id)
return next
})
setSelectedTransaction(null)
setIsConfirmingMatch(false)
}, 350)
} catch {
toast({ title: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' })
setIsConfirmingMatch(false)
}
}
async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise<boolean> {
try {
const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: invoiceId }),
})
const result = await response.json()
if (!response.ok) {
toast({ title: 'Fel', description: result.error || 'Kunde inte matcha faktura', variant: 'destructive' })
return false
}
const transaction = transactions.find((t) => t.id === transactionId)
const invoiceNumber = transaction?.potential_invoice?.invoice_number || ''
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? {
...t,
invoice_id: invoiceId,
potential_invoice_id: null,
potential_invoice: undefined,
is_business: true,
category: 'income_services' as TransactionCategory,
journal_entry_id: result.journal_entry_id,
}
: t
)
)
toast({ title: 'Faktura matchad', description: `Faktura ${invoiceNumber} markerad som betald` })
return true
} catch {
toast({ title: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' })
return false
}
}
async function handleCreateTransaction(data: CreateTransactionInput) {
setIsCreating(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
toast({ title: 'Fel', description: 'Du måste vara inloggad', variant: 'destructive' })
setIsCreating(false)
return
}
const { data: transaction, error } = await supabase
.from('transactions')
.insert({
user_id: user.id,
date: data.date,
description: data.description,
amount: data.amount,
currency: data.currency,
category: data.category || 'uncategorized',
is_business: data.is_business,
notes: data.notes,
})
.select()
.single()
if (error) {
toast({ title: 'Fel', description: error.message, variant: 'destructive' })
} else {
toast({ title: 'Transaktion tillagd', description: `${data.description} har lagts till` })
setTransactions([transaction, ...transactions])
setIsDialogOpen(false)
}
setIsCreating(false)
}
function handleTransactionBooked(transactionId: string, journalEntryId: string) {
setExitingIds((prev) => new Set(prev).add(transactionId))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? { ...t, is_business: true, journal_entry_id: journalEntryId }
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(transactionId)
return next
})
}, 350)
setBookingDialogOpen(false)
setBookingDialogTransaction(null)
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
}
// Batch mode handlers
function toggleBatchSelect(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function exitBatchMode() {
setIsBatchMode(false)
setSelectedIds(new Set())
}
async function handleBatchMarkPrivate() {
const ids = Array.from(selectedIds)
setBatchProgress({ done: 0, total: ids.length })
for (let i = 0; i < ids.length; i++) {
await handleCategorize(ids[i], false, 'private')
setBatchProgress({ done: i + 1, total: ids.length })
}
setBatchProgress(null)
toast({ title: 'Klart', description: `${ids.length} transaktioner markerade som privat` })
exitBatchMode()
}
async function handleBatchCategorize(category: TransactionCategory, vatTreatment?: VatTreatment) {
const ids = Array.from(selectedIds)
setBatchProgress({ done: 0, total: ids.length })
let successes = 0
const failures: string[] = []
for (let i = 0; i < ids.length; i++) {
const result = await handleCategorize(ids[i], true, category, vatTreatment)
if (result) {
successes++
} else {
const tx = transactions.find((t) => t.id === ids[i])
failures.push(tx?.description || ids[i])
}
setBatchProgress({ done: i + 1, total: ids.length })
}
setBatchProgress(null)
setShowBatchSelector(false)
if (failures.length === 0) {
toast({ title: 'Klart', description: `${successes} transaktioner bokförda` })
} else {
toast({
title: 'Delvis klart',
description: `${successes} lyckades, ${failures.length} misslyckades: ${failures.slice(0, 3).join(', ')}${failures.length > 3 ? '...' : ''}`,
variant: 'destructive',
})
}
exitBatchMode()
}
async function openSwipeView() {
try {
// Match invoices to transactions
await fetch('/api/transactions/batch-match-invoices', { method: 'POST' })
.then((r) => r.json())
.then((data) => {
if (data.matched > 0) fetchTransactions()
})
} catch {
// Non-critical
}
try {
// Run document matching sweep for latest inbox matches
await fetch('/api/documents/match-sweep', { method: 'POST' })
.then((r) => r.json())
.then((data) => {
if (data.data?.matched > 0) fetchTransactions()
})
} catch {
// Non-critical
}
const uncatIds = uncategorizedTransactions.map((t) => t.id)
await fetchCategorySuggestions(uncatIds)
setShowSwipeView(true)
}
function openMatchDialog(transaction: TransactionWithInvoice) {
setSelectedTransaction(transaction)
setMatchDialogOpen(true)
}
function openCategoryDialog(transaction: TransactionWithInvoice) {
setTemplatePickerTransaction(transaction)
setTemplatePickerOpen(true)
}
function handleOpenQuickReview(transaction: TransactionWithInvoice, suggestion: SuggestedCategory) {
const allCategories = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES]
const label = allCategories.find((c) => c.value === suggestion.category)?.label || suggestion.label
setQuickReviewTransaction(transaction)
setQuickReviewCategory(suggestion.category)
setQuickReviewLabel(label)
setQuickReviewOpen(true)
}
function handleTemplateSelected(template: BookingTemplate) {
setTemplatePickerOpen(false)
const tx = templatePickerTransaction
if (!tx) return
setQuickReviewTransaction(tx)
setQuickReviewCategory(template.fallback_category)
setQuickReviewLabel(template.name_sv)
setQuickReviewTemplate(template)
setQuickReviewTemplateId(template.id)
setQuickReviewOpen(true)
}
function handleOpenTemplateReview(transaction: TransactionWithInvoice, templateId: string) {
const template = getTemplateById(templateId)
if (!template) return
setQuickReviewTransaction(transaction)
setQuickReviewCategory(template.fallback_category)
setQuickReviewLabel(template.name_sv)
setQuickReviewTemplate(template)
setQuickReviewTemplateId(template.id)
setQuickReviewOpen(true)
}
function handleChangeTemplate() {
setQuickReviewOpen(false)
if (quickReviewTransaction) {
setTemplatePickerTransaction(quickReviewTransaction)
setTemplatePickerOpen(true)
}
}
function handleManualBooking() {
setTemplatePickerOpen(false)
if (templatePickerTransaction) {
setBookingDialogTransaction(templatePickerTransaction)
setBookingDialogOpen(true)
}
}
async function handleQuickReviewConfirm(
id: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined,
templateId?: string
): Promise<string | null> {
const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId)
if (journalEntryId) {
setQuickReviewOpen(false)
setQuickReviewTransaction(null)
setQuickReviewCategory(null)
setQuickReviewTemplate(null)
setQuickReviewTemplateId(undefined)
}
return journalEntryId
}
function openDescribeDialog(transaction: TransactionWithInvoice) {
setDescribeDialogTransaction(transaction)
setDescribeDialogOpen(true)
}
function handleDescribeCategorized(transactionId: string, journalEntryId: string | null) {
setExitingIds((prev) => new Set(prev).add(transactionId))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? { ...t, is_business: true, journal_entry_id: journalEntryId }
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(transactionId)
return next
})
}, 350)
}
function handleBatchApplied() {
fetchTransactions()
}
// Swipe view
if (showSwipeView && uncategorizedTransactions.length > 0) {
return (
<SwipeCategorizationView
transactions={uncategorizedTransactions}
suggestions={categorySuggestions}
templateSuggestions={templateSuggestions}
onCategorize={handleCategorize}
onMatchInvoice={handleMatchInvoice}
onClose={() => setShowSwipeView(false)}
entityType={entityType as EntityType}
/>
)
}
return (
<div className="space-y-6">
{/* Status bar with mode toggle */}
<TransactionStatusBar
uncategorizedCount={uncategorizedTransactions.length}
invoiceMatchCount={transactionsWithMatches.length}
mode={mode}
onModeChange={setMode}
onOpenSwipeView={openSwipeView}
onOpenCreateDialog={() => setIsDialogOpen(true)}
isLoadingSuggestions={isLoadingSuggestions}
isBatchMode={isBatchMode}
onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))}
/>
{/* Content based on mode */}
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-5 bg-muted rounded w-48" />
<div className="h-4 bg-muted rounded w-24" />
</div>
<div className="h-6 bg-muted rounded w-20" />
</div>
</CardContent>
</Card>
))}
</div>
) : mode === 'inbox' ? (
uncategorizedTransactions.length === 0 ? (
<InboxZeroState
hasTransactions={transactions.length > 0}
onCreateTransaction={() => setIsDialogOpen(true)}
/>
) : (
<div className="space-y-2">
<AnimatePresence mode="popLayout">
{uncategorizedTransactions.map((transaction) => (
<TransactionInboxCard
key={transaction.id}
transaction={transaction}
suggestions={categorySuggestions[transaction.id]}
templateSuggestions={templateSuggestions[transaction.id]}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(transaction.id)}
entityType={entityType}
onCategorize={handleCategorize}
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onOpenDescribe={ENABLED_EXTENSION_IDS.has('ai-categorization') ? openDescribeDialog : undefined}
onOpenQuickReview={handleOpenQuickReview}
onOpenTemplateReview={handleOpenTemplateReview}
onToggleSelect={toggleBatchSelect}
/>
))}
</AnimatePresence>
</div>
)
) : (
<TransactionHistoryList
transactions={transactions}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
/>
)}
{/* Batch mode floating action bar */}
{isBatchMode && selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-background border rounded-xl shadow-lg px-4 py-3">
<Badge variant="secondary">{selectedIds.size} valda</Badge>
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
<X className="mr-1 h-3 w-3" />
Avmarkera
</Button>
<Button variant="outline" size="sm" onClick={handleBatchMarkPrivate}>
Markera som privat
</Button>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
Bokför {selectedIds.size} st
</Button>
</div>
)}
{/* Dialogs */}
<BatchCategorySelector
open={showBatchSelector}
onOpenChange={setShowBatchSelector}
selectedCount={selectedIds.size}
onSelectCategory={handleBatchCategorize}
progress={batchProgress}
/>
<InvoiceMatchDialog
open={matchDialogOpen}
onOpenChange={setMatchDialogOpen}
transaction={selectedTransaction}
isConfirming={isConfirmingMatch}
onConfirm={handleConfirmInvoiceMatch}
/>
<TransactionBookingDialog
open={bookingDialogOpen}
onOpenChange={setBookingDialogOpen}
transaction={bookingDialogTransaction}
onBooked={handleTransactionBooked}
/>
<Dialog open={templatePickerOpen} onOpenChange={setTemplatePickerOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Välj mall</DialogTitle>
</DialogHeader>
<TemplatePicker
direction={templatePickerTransaction && templatePickerTransaction.amount < 0 ? 'expense' : 'income'}
entityType={entityType as EntityType}
suggestedTemplates={templatePickerTransaction ? templateSuggestions[templatePickerTransaction.id] : undefined}
onSelect={handleTemplateSelected}
/>
<div className="pt-2 border-t">
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" onClick={handleManualBooking}>
Ange konton manuellt...
</Button>
</div>
</DialogContent>
</Dialog>
<QuickReviewDialog
key={quickReviewTransaction?.id ?? '' + String(quickReviewCategory) + String(quickReviewTemplateId)}
open={quickReviewOpen}
onOpenChange={setQuickReviewOpen}
transaction={quickReviewTransaction}
category={quickReviewCategory}
categoryLabel={quickReviewLabel}
defaultAccount={quickReviewCategory ? getDefaultAccountForCategory(quickReviewCategory) : ''}
defaultVat={quickReviewCategory ? (getDefaultVatTreatmentForCategory(quickReviewCategory) ?? 'none') : 'none'}
entityType={entityType as EntityType}
template={quickReviewTemplate}
templateId={quickReviewTemplateId}
onConfirm={handleQuickReviewConfirm}
onChangeTemplate={handleChangeTemplate}
/>
<DescribeTransactionDialog
open={describeDialogOpen}
onOpenChange={setDescribeDialogOpen}
transaction={describeDialogTransaction}
onCategorized={handleDescribeCategorized}
onBatchApplied={handleBatchApplied}
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Lägg till transaktion</DialogTitle>
</DialogHeader>
<TransactionForm onSubmit={handleCreateTransaction} isLoading={isCreating} />
</DialogContent>
</Dialog>
</div>
)
}