feat: transaction categorization UX improvements and description matching

Add journal entry preview, human-readable account names, auto-apply VAT,
fallback template suggestions, example prompts, invoice match comparison,
and batch result feedback. Also includes user-description-match extension,
describe/batch-describe API routes, improved AI categorization with multi-
suggestion support, and template embedding search.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-24 16:02:06 +01:00
co-authored by Claude Opus 4.6
parent 6cef4e11eb
commit 3e7fa45ed6
40 changed files with 2401 additions and 84 deletions
@@ -10,18 +10,22 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp, MessageSquareText } from 'lucide-react'
import DescribeTransactionDialog from './DescribeTransactionDialog'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
interface SwipeCategorizationViewProps {
transactions: TransactionWithInvoice[]
suggestions?: Record<string, SuggestedCategory[]>
templateSuggestions?: Record<string, SuggestedTemplate[]>
onCategorize: CategorizeHandler
onMatchInvoice?: MatchInvoiceHandler
onClose: () => void
@@ -33,6 +37,7 @@ const incomeCategories = INCOME_CATEGORIES
export default function SwipeCategorizationView({
transactions,
suggestions,
templateSuggestions,
onCategorize,
onMatchInvoice,
onClose,
@@ -52,6 +57,8 @@ export default function SwipeCategorizationView({
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [showDescribeDialog, setShowDescribeDialog] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
// Clear VAT treatment when switching to a liability/equity account (class 2)
useEffect(() => {
@@ -113,6 +120,7 @@ export default function SwipeCategorizationView({
setPendingCategory(category)
setAccountOverride(defaultAccount)
setVatTreatment(defaultVat ?? 'none')
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
@@ -366,6 +374,15 @@ export default function SwipeCategorizationView({
</div>
</div>
{/* Journal entry preview */}
<JournalEntryPreview
amount={currentTransaction.amount}
currency={currentTransaction.currency}
category={pendingCategory}
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
accountOverride={accountOverride}
/>
{/* Account override */}
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
@@ -382,15 +399,27 @@ export default function SwipeCategorizationView({
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1">
<VatTreatmentSelect
value={isLiabilityAccount ? 'none' : vatTreatment}
onValueChange={setVatTreatment}
disabled={isLiabilityAccount}
/>
{isLiabilityAccount && (
<p className="text-xs text-muted-foreground mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
value={vatTreatment}
onValueChange={setVatTreatment}
/>
) : (
<p className="text-sm">
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
{' '}
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
Andra
</button>
</p>
)}
</div>
</div>
@@ -641,7 +670,7 @@ export default function SwipeCategorizationView({
<div className="flex flex-col">
<span className="text-sm font-medium">{suggestion.label}</span>
{suggestion.account && (
<span className="text-xs text-muted-foreground">{suggestion.account}</span>
<span className="text-xs text-muted-foreground">{formatAccountWithName(suggestion.account)}</span>
)}
</div>
</Button>
@@ -650,6 +679,45 @@ export default function SwipeCategorizationView({
</div>
)}
{/* Fallback templates when no strong suggestion */}
{(() => {
const txSuggestions = suggestions?.[currentTransaction.id]
const topConfidence = txSuggestions?.[0]?.confidence ?? 0
const templates = templateSuggestions?.[currentTransaction.id]
if (topConfidence < 0.55 && templates && templates.length > 0) {
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground text-center">Osaker? Prova dessa mallar:</p>
<div className="grid grid-cols-2 gap-2">
{templates.slice(0, 3).map((tmpl) => (
<Button
key={tmpl.template_id}
variant="outline"
className="h-auto py-2.5 px-3 text-left justify-start border-dashed"
onClick={() => setShowDescribeDialog(true)}
disabled={isProcessing}
>
<span className="text-sm">{tmpl.name_sv}</span>
</Button>
))}
</div>
</div>
)
}
return null
})()}
{/* Describe transaction button */}
<Button
variant="outline"
className="w-full"
onClick={() => setShowDescribeDialog(true)}
disabled={isProcessing}
>
<MessageSquareText className="mr-2 h-4 w-4" />
Beskriv transaktion...
</Button>
{/* Categorization button */}
<Button
className="w-full"
@@ -678,6 +746,20 @@ export default function SwipeCategorizationView({
</Button>
</div>
</div>
<DescribeTransactionDialog
open={showDescribeDialog}
onOpenChange={setShowDescribeDialog}
transaction={currentTransaction}
onCategorized={() => {
setShowDescribeDialog(false)
moveToNext()
}}
onBatchApplied={() => {
setShowDescribeDialog(false)
moveToNext()
}}
/>
</div>
)
}