feat: add transaction booking dialog with manual journal entry

Replace CategoryExpandedDialog with TransactionBookingDialog that embeds the
JournalEntryForm for full manual booking. Add /api/transactions/[id]/book
endpoint. Add VAT treatment selector to CategoryExpandedDialog. Add booking
buttons to TransactionHistoryList and improve TransactionInboxCard actions.
Fix animation timing in transactions page state updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Emil
2026-02-23 12:59:47 +01:00
co-authored by Claude Opus 4.6
parent 3ec0884de4
commit 44231d0000
8 changed files with 533 additions and 64 deletions
+50 -39
View File
@@ -17,7 +17,7 @@ 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 CategoryExpandedDialog from '@/components/transactions/CategoryExpandedDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
@@ -44,10 +44,9 @@ export default function TransactionsPage() {
const [selectedTransaction, setSelectedTransaction] = useState<TransactionWithInvoice | null>(null)
const [isConfirmingMatch, setIsConfirmingMatch] = useState(false)
// Category expanded dialog
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false)
const [categoryDialogTransaction, setCategoryDialogTransaction] = useState<TransactionWithInvoice | null>(null)
const [categoryDialogProcessing, setCategoryDialogProcessing] = useState(false)
// Booking dialog (journal entry form)
const [bookingDialogOpen, setBookingDialogOpen] = useState(false)
const [bookingDialogTransaction, setBookingDialogTransaction] = useState<TransactionWithInvoice | null>(null)
// Set of transaction IDs that are animating out (just categorized)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
@@ -164,7 +163,16 @@ export default function TransactionsPage() {
// 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' })
} 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) =>
@@ -178,17 +186,9 @@ export default function TransactionsPage() {
next.delete(id)
return next
})
setProcessingId(null)
}, 350)
if (result.journal_entry_created) {
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
} 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' })
}
setProcessingId(null)
return true
} catch {
toast({ title: 'Fel', description: 'Något gick fel vid bokföring', variant: 'destructive' })
@@ -218,6 +218,12 @@ export default function TransactionsPage() {
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(() => {
@@ -241,18 +247,13 @@ export default function TransactionsPage() {
next.delete(selectedTransaction.id)
return next
})
setSelectedTransaction(null)
setIsConfirmingMatch(false)
}, 350)
toast({
title: 'Faktura matchad',
description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`,
})
setMatchDialogOpen(false)
setSelectedTransaction(null)
} catch {
toast({ title: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' })
setIsConfirmingMatch(false)
}
setIsConfirmingMatch(false)
}
async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise<boolean> {
@@ -329,15 +330,25 @@ export default function TransactionsPage() {
setIsCreating(false)
}
async function handleCategoryDialogSelect(category: TransactionCategory) {
if (!categoryDialogTransaction) return
setCategoryDialogProcessing(true)
const success = await handleCategorize(categoryDialogTransaction.id, true, category)
setCategoryDialogProcessing(false)
if (success) {
setCategoryDialogOpen(false)
setCategoryDialogTransaction(null)
}
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
@@ -401,8 +412,8 @@ export default function TransactionsPage() {
}
function openCategoryDialog(transaction: TransactionWithInvoice) {
setCategoryDialogTransaction(transaction)
setCategoryDialogOpen(true)
setBookingDialogTransaction(transaction)
setBookingDialogOpen(true)
}
// Swipe view
@@ -481,6 +492,7 @@ export default function TransactionsPage() {
<TransactionHistoryList
transactions={transactions}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
/>
)}
@@ -518,12 +530,11 @@ export default function TransactionsPage() {
onConfirm={handleConfirmInvoiceMatch}
/>
<CategoryExpandedDialog
open={categoryDialogOpen}
onOpenChange={setCategoryDialogOpen}
transaction={categoryDialogTransaction}
onSelectCategory={handleCategoryDialogSelect}
isProcessing={categoryDialogProcessing}
<TransactionBookingDialog
open={bookingDialogOpen}
onOpenChange={setBookingDialogOpen}
transaction={bookingDialogTransaction}
onBooked={handleTransactionBooked}
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeTransaction,
makeJournalEntry,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
const mockCreateJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
}))
import { POST } from '../route'
describe('POST /api/transactions/[id]/book', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const validBody = {
fiscal_period_id: 'period-1',
entry_date: '2025-01-15',
description: 'Test booking',
lines: [
{ account_number: '6200', debit_amount: 500, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 500 },
],
}
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 when missing required fields', async () => {
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: { fiscal_period_id: 'period-1' },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('Missing required fields')
})
it('returns 404 when transaction not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/transactions/tx-999/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toBe('Transaction not found')
})
it('returns 409 when transaction already has a journal entry', async () => {
const tx = makeTransaction({
id: 'tx-1',
journal_entry_id: 'je-existing',
})
enqueue({ data: tx, error: null })
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toBe('Transaction already has a journal entry')
})
it('returns 400 when journal entry creation fails (engine error)', async () => {
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
enqueue({ data: tx, error: null })
mockCreateJournalEntry.mockRejectedValue(new Error('Entry is not balanced'))
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Entry is not balanced')
})
it('creates journal entry and links to transaction (happy path)', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
journal_entry_id: null,
})
const je = makeJournalEntry({ id: 'je-new' })
// Fetch transaction
enqueue({ data: tx, error: null })
mockCreateJournalEntry.mockResolvedValue(je)
// Update transaction
enqueue({ data: null, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
journal_entry_id: string
data: { id: string }
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-new')
expect(body.data.id).toBe('je-new')
expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', {
fiscal_period_id: 'period-1',
entry_date: '2025-01-15',
description: 'Test booking',
source_type: 'bank_transaction',
source_id: 'tx-1',
lines: validBody.lines,
})
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'transaction.categorized' })
)
})
it('returns 500 when transaction update fails', async () => {
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null })
mockCreateJournalEntry.mockResolvedValue(je)
// Update fails
enqueue({ data: null, error: { message: 'Update failed' } })
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(body.error).toBe('Failed to update transaction')
})
})
+115
View File
@@ -0,0 +1,115 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import type { CreateJournalEntryLineInput, Transaction } from '@/types'
ensureInitialized()
interface BookRequest {
fiscal_period_id: string
entry_date: string
description: string
lines: CreateJournalEntryLineInput[]
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: BookRequest = await request.json()
const { fiscal_period_id, entry_date, description, lines } = body
if (!fiscal_period_id || !entry_date || !description || !lines?.length) {
return NextResponse.json(
{ error: 'Missing required fields: fiscal_period_id, entry_date, description, lines' },
{ status: 400 }
)
}
// Fetch transaction (validates ownership)
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Reject if already booked
if (transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Transaction already has a journal entry' },
{ status: 409 }
)
}
// Create journal entry via the engine
let journalEntry
try {
journalEntry = await createJournalEntry(user.id, {
fiscal_period_id,
entry_date,
description,
source_type: 'bank_transaction',
source_id: id,
lines,
})
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
{ status: 400 }
)
}
// Link transaction to the journal entry
const { error: updateError } = await supabase
.from('transactions')
.update({
journal_entry_id: journalEntry.id,
is_business: true,
category: 'uncategorized',
})
.eq('id', id)
if (updateError) {
return NextResponse.json(
{ error: 'Failed to update transaction' },
{ status: 500 }
)
}
// Emit event (non-blocking)
try {
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
account: lines[0]?.account_number || '',
taxCode: '',
userId: user.id,
},
})
} catch {
// Non-critical
}
return NextResponse.json({
data: journalEntry,
journal_entry_id: journalEntry.id,
success: true,
})
}
@@ -1,18 +1,20 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory } from '@/types'
import type { TransactionCategory, VatTreatment } from '@/types'
interface CategoryExpandedDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onSelectCategory: (category: TransactionCategory) => void
onSelectCategory: (category: TransactionCategory, vatTreatment?: VatTreatment) => void
isProcessing: boolean
}
@@ -23,10 +25,23 @@ export default function CategoryExpandedDialog({
onSelectCategory,
isProcessing,
}: CategoryExpandedDialogProps) {
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
useEffect(() => {
if (open) {
setVatTreatment('standard_25')
}
}, [open, transaction?.id])
if (!transaction) return null
const isIncome = transaction.amount > 0
const handleSelectCategory = (category: TransactionCategory) => {
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
onSelectCategory(category, resolvedVat)
}
return (
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
<DialogContent className="max-w-md">
@@ -62,6 +77,26 @@ export default function CategoryExpandedDialog({
</p>
</div>
{/* VAT treatment selector */}
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
<Select
value={vatTreatment}
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VAT_TREATMENT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Category grid */}
<div className="space-y-4 py-2">
<div>
@@ -73,7 +108,7 @@ export default function CategoryExpandedDialog({
variant="outline"
size="sm"
className="justify-start text-xs"
onClick={() => onSelectCategory(cat.value)}
onClick={() => handleSelectCategory(cat.value)}
disabled={isProcessing}
>
{cat.label}
@@ -90,7 +125,7 @@ export default function CategoryExpandedDialog({
variant="outline"
size="sm"
className="justify-start text-xs"
onClick={() => onSelectCategory(cat.value)}
onClick={() => handleSelectCategory(cat.value)}
disabled={isProcessing}
>
{cat.label}
@@ -48,6 +48,13 @@ export default function SwipeCategorizationView({
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
const [accounts, setAccounts] = useState<BASAccount[]>([])
// 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() {
@@ -375,7 +382,7 @@ export default function SwipeCategorizationView({
<Button
className="w-full"
onClick={handleReviewConfirm}
disabled={isProcessing}
disabled={isProcessing || !accountOverride}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokför...' : 'Bokför'}
@@ -0,0 +1,94 @@
'use client'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { TransactionWithInvoice } from './transaction-types'
interface TransactionBookingDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onBooked: (transactionId: string, journalEntryId: string) => void
}
function buildInitialLines(transaction: TransactionWithInvoice): FormLine[] {
const amount = Math.round(Math.abs(transaction.amount_sek ?? transaction.amount) * 100) / 100
const amountStr = amount.toFixed(2)
const isExpense = transaction.amount < 0
if (isExpense) {
return [
{ account_number: '', debit_amount: amountStr, credit_amount: '', line_description: '' },
{ account_number: '1930', debit_amount: '', credit_amount: amountStr, line_description: 'Företagskonto' },
]
}
return [
{ account_number: '1930', debit_amount: amountStr, credit_amount: '', line_description: 'Företagskonto' },
{ account_number: '', debit_amount: '', credit_amount: amountStr, line_description: '' },
]
}
export default function TransactionBookingDialog({
open,
onOpenChange,
transaction,
onBooked,
}: TransactionBookingDialogProps) {
if (!transaction) return null
const isIncome = transaction.amount > 0
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokför transaktion</DialogTitle>
<DialogDescription>
Skapa en verifikation för transaktionen
</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
? 'bg-success/10 text-success'
: 'bg-destructive/10 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>
<JournalEntryForm
key={transaction.id}
embedded
initialLines={buildInitialLines(transaction)}
initialDate={transaction.date}
initialDescription={transaction.description}
submitUrl={`/api/transactions/${transaction.id}/book`}
sourceType="bank_transaction"
sourceId={transaction.id}
onEntryCreated={(entryId) => onBooked(transaction.id, entryId)}
/>
</DialogContent>
</Dialog>
)
}
@@ -14,11 +14,13 @@ import type { HistoryFilter } from './transaction-types'
interface TransactionHistoryListProps {
transactions: TransactionWithInvoice[]
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
}
export default function TransactionHistoryList({
transactions,
onOpenMatchDialog,
onOpenCategoryDialog,
}: TransactionHistoryListProps) {
const [searchTerm, setSearchTerm] = useState('')
const [filter, setFilter] = useState<HistoryFilter>('all')
@@ -134,7 +136,11 @@ export default function TransactionHistoryList({
) : transaction.is_business === null ? (
<>
<span>·</span>
<Badge variant="outline" className="text-warning border-warning">
<Badge
variant="outline"
className="text-warning border-warning cursor-pointer hover:bg-warning/10"
onClick={() => onOpenCategoryDialog(transaction)}
>
Ej bokförd
</Badge>
</>
@@ -155,20 +161,32 @@ export default function TransactionHistoryList({
</div>
</div>
</div>
<div className="text-right">
<p
className={`font-medium ${
transaction.amount > 0 ? 'text-success' : ''
}`}
>
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
{transaction.currency !== 'SEK' && transaction.amount_sek && (
<p className="text-sm text-muted-foreground">
{formatCurrency(transaction.amount_sek)}
</p>
<div className="flex items-center gap-3">
{transaction.is_business === null && !transaction.journal_entry_id && (
<Button
size="sm"
variant="default"
className="h-7 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
>
Bokför
</Button>
)}
<div className="text-right">
<p
className={`font-medium ${
transaction.amount > 0 ? 'text-success' : ''
}`}
>
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
{transaction.currency !== 'SEK' && transaction.amount_sek && (
<p className="text-sm text-muted-foreground">
{formatCurrency(transaction.amount_sek)}
</p>
)}
</div>
</div>
</div>
</CardContent>
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, MoreHorizontal, Loader2 } from 'lucide-react'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2 } from 'lucide-react'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
@@ -178,15 +178,15 @@ export default function TransactionInboxCard({
Privat
</Button>
{/* More options */}
{/* Open category dialog */}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-muted-foreground"
variant={!hasInvoiceMatch && !topSuggestion ? 'default' : 'outline'}
className="h-8 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
disabled={isProcessing || isDisabled}
>
<MoreHorizontal className="h-4 w-4" />
{!hasInvoiceMatch && !topSuggestion ? 'Bokför' : 'Bokför manuellt...'}
</Button>
</div>
)}