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:
@@ -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