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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user