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
@@ -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>
)}